enablement-build-template-generate 1.0.20 → 1.3.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 CHANGED
@@ -12,11 +12,13 @@ npx enablement-build-template-generate@latest [flags]
12
12
 
13
13
  ## How it works
14
14
 
15
- 1. **Detect platform** — Checks for `manifest.json` (preferred) or `templated.json` (legacy) in `.cicd/` (ADO) or `.github/` (GitHub). Defaults to `ado` if none are found, or use `--platform` to override.
16
- 2. **Read config** — If `manifest.json` is present, reads the `templated` section (each package maps to its own job template file). If only `templated.json` is present, reads the legacy `{ rootPath, template }` format and globs for packages automatically.
17
- 3. **Expand job templates** — For each package, reads its job snippet template and fills in placeholders. When using `manifest.json`, metadata (`folderName`, `packageFolder`) comes from `manifest.cicd`; when using `templated.json`, package names are read from each folder's manifest file.
18
- 4. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
19
- 5. **Write output** — Writes the final file one level above the template directory (`.github/templates/` → `.github/` or `.cicd/templates/` → `.cicd/`), wrapped in `DO NOT EDIT` banners.
15
+ 1. **Select platform** — Uses Azure DevOps (`.cicd/`) by default. Pass `--platform github` to use GitHub (`.github/`).
16
+ 2. **Read config** — `{configDir}/templates/templated.json` is the source of truth. If `manifest.json` still has a `templated` section (from older tooling), it's read once, merged into `templated.json`, and removed from `manifest.json`. A `templated.json` in the legacy `{ rootPath, template }` shape is converted to the current format (packages discovered by globbing `rootPath`).
17
+ 3. **Detect new packages** — Any package not yet assigned a template (scanned via `--children`/auto-detected workspace roots) is classified by type (node/python/dotnet/docker) and assigned a template, reusing the convention of other packages in the same folder or of the same type where possible.
18
+ 4. **Persist** — `templated.json` (and `manifest.json`, if it needed migrating) is written back, only when something actually changed.
19
+ 5. **Expand job templates** — For each package, reads its job snippet template and fills in placeholders (`folderName`/`packageFolder`/`metadata` come from `templated.json`'s own `cicd` section). Packages are ordered so that, per `manifest.json`'s `dependencies` graph (when present), a package's local dependencies are generated before it, as much as possible.
20
+ 6. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
21
+ 7. **Write output** — Writes the final file one level above the template directory (`.github/templates/` → `.github/` or `.cicd/templates/` → `.cicd/`), wrapped in `DO NOT EDIT` banners.
20
22
 
21
23
  ---
22
24
 
@@ -24,22 +26,22 @@ npx enablement-build-template-generate@latest [flags]
24
26
 
25
27
  | Flag | Default | Description |
26
28
  |---|---|---|
27
- | `--platform <name>` | auto-detect | Force the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). Auto-detected from `manifest.json` or `templated.json` presence in `.cicd/` or `.github/`. |
28
- | `--exclude <list>` | — | Comma- or space-separated list of package folder names to skip during generation. |
29
+ | `--platform <name>` | `ado` | Select the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). |
30
+ | `--children <dirs>` | auto-detect | Comma- or space-separated list of top-level directories to scan when detecting new (not-yet-configured) packages. When omitted, directories are read from `pnpm-workspace.yaml` or `package.json` workspaces automatically. |
31
+ | `--exclude <list>` | — | Comma- or space-separated list of names to skip: package folder names during generation, and top-level directories (from `--children` or auto-detection) during new-package scanning. |
29
32
  | `--debug` | off | Print verbose output. |
30
33
 
31
34
  ---
32
35
 
33
36
  ## Config source
34
37
 
35
- The generator supports two config formats, checked in this order:
38
+ ### `templated.json` (current format)
36
39
 
37
- ### 1. `manifest.json` (preferred)
38
-
39
- Reads the `templated` section of `{configDir}/manifest.json` (produced by `enablement-build-manifest`). Each key is the **output filename**; each value maps a placeholder variable name to a per-package template mapping.
40
+ `{configDir}/templates/templated.json` is the source of truth for template routing. Each key under `templated` is the **output filename**; each value maps a placeholder variable name to a per-package template mapping. `cicd` carries the per-package metadata generation needs.
40
41
 
41
42
  ```json
42
43
  {
44
+ "version": 2,
43
45
  "templated": {
44
46
  "build.yml": {
45
47
  "PACKAGES": {
@@ -48,30 +50,24 @@ Reads the `templated` section of `{configDir}/manifest.json` (produced by `enabl
48
50
  "my-scope/python-lib": ["build_python_package.yml", "build_scan.yml"]
49
51
  }
50
52
  }
53
+ },
54
+ "cicd": {
55
+ "my-scope/component-a": { "folderName": "component-a", "packageFolder": "packages" }
51
56
  }
52
57
  }
53
58
  ```
54
59
 
55
60
  Each package name maps to a **single template filename** or an **array of template filenames** (all expanded in order for that package). Template lookup tries the active platform templates directory first (`.cicd/templates/` or `.github/templates/`) and then falls back to the other platform directory if a file is missing.
56
61
 
57
- Package metadata (`folderName`, `packageFolder`) is read from `manifest.cicd[packageName]` and used to fill template placeholders.
62
+ Package metadata (`folderName`, `packageFolder`, `metadata`) is read from `templated.json`'s own `cicd` map and used to fill template placeholders.
58
63
 
59
- ### 2. `templated.json` (legacy fallback)
64
+ A `templated.json` in the legacy `{ rootPath, template }` shape (no top-level `templated`/`version` key) is converted to the current format automatically — packages are discovered by globbing `rootPath` for `package.json`/`version.json`/`pyproject.toml`, and the result is written back.
60
65
 
61
- When `manifest.json` is absent, reads `{configDir}/templates/templated.json`. Each entry provides a `rootPath` to glob for packages and a single `template` applied to all discovered packages. Template lookup still uses active-platform-first, then falls back to the other platform templates folder if needed.
66
+ ### `manifest.json` (one-time migration source + dependency graph)
62
67
 
63
- ```json
64
- {
65
- "build.yml": {
66
- "PACKAGES": {
67
- "rootPath": "../../packages",
68
- "template": "build_node_component.yml"
69
- }
70
- }
71
- }
72
- ```
68
+ If `{configDir}/manifest.json` still has a `templated` section (from older tooling, or `enablement-build-manifest` before it stopped writing one), it's merged into `templated.json` and removed from `manifest.json` — existing assignments in `templated.json` win over stale ones from `manifest.json`.
73
69
 
74
- Package names are read from each folder's `package.json`, `version.json`, or `pyproject.toml`.
70
+ Independently of that migration, when `manifest.json` exists its `dependencies` map (`{ "<package>": ["<local-dep>", ...] }`) is used to order generation: within each placeholder, a package's local dependencies are generated before it, as much as possible (dependencies outside that placeholder's package list, or on a package that also depends back on it, are simply skipped rather than causing an error).
75
71
 
76
72
  ---
77
73
 
@@ -101,9 +97,9 @@ Each job snippet template is expanded once per package. The following placeholde
101
97
  | `{full}` | Full npm package name including scope (e.g. `@scope/my-component`) |
102
98
  | `{path}` | The package folder root (from `cicd[pkg].packageFolder`, e.g. `packages`) |
103
99
  | `{other}` | Expands to a comma-separated list of paths to every **other** package in the same variable — useful for pipeline dependency declarations |
104
- | `{metadataKey}` | Custom placeholders like `{owner}` or `{tier}` are replaced from `manifest.cicd[packageName].metadata` (**manifest.json mode only**) |
100
+ | `{metadataKey}` | Custom placeholders like `{owner}` or `{tier}` are replaced from `templated.json`'s `cicd[packageName].metadata` |
105
101
 
106
- In `templated.json` legacy mode, custom metadata placeholders are not supported and remain unchanged in output.
102
+ Custom metadata placeholders aren't supported for a package still being discovered via the legacy `{ rootPath, template }` conversion (they remain unchanged in output) — once converted, `templated.json`'s `cicd` map can carry `metadata` for any package.
107
103
 
108
104
  ---
109
105
 
@@ -111,25 +107,25 @@ In `templated.json` legacy mode, custom metadata placeholders are not supported
111
107
 
112
108
  Job template files live in the templates subfolder; document templates and generated output files are one level up.
113
109
 
114
- **Azure DevOps** (when `.cicd/manifest.json` exists):
110
+ **Azure DevOps**:
115
111
  ```
116
112
  .cicd/
117
- ├── manifest.json ← source of truth for templated config (preferred)
113
+ ├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
118
114
  ├── build.yml ← generated output
119
115
  └── templates/
120
116
  ├── build.yml ← document template
121
- ├── templated.json ← legacy config (used when manifest.json is absent)
117
+ ├── templated.json ← source of truth for templated config
122
118
  └── build_node_component.yml ← job snippet template
123
119
  ```
124
120
 
125
- **GitHub Actions** (when `.github/manifest.json` exists):
121
+ **GitHub Actions**:
126
122
  ```
127
123
  .github/
128
- ├── manifest.json ← source of truth for templated config (preferred)
124
+ ├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
129
125
  ├── build.yml ← generated output
130
126
  └── templates/
131
127
  ├── build.yml ← document template
132
- ├── templated.json ← legacy config (used when manifest.json is absent)
128
+ ├── templated.json ← source of truth for templated config
133
129
  └── build_node_component.yml ← job snippet template
134
130
  ```
135
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enablement-build-template-generate",
3
- "version": "1.0.20",
3
+ "version": "1.3.0",
4
4
  "description": "Generates build yaml for mono repos",
5
5
  "type": "module",
6
6
  "module": "./src/index.mjs",
@@ -8,16 +8,12 @@
8
8
  "bin": "./src/index.mjs",
9
9
  "dependencies": {
10
10
  "globby": "^16.2.0",
11
- "user": "^0.0.0"
11
+ "user": "^0.0.0",
12
+ "enablement-build-manifest": "1.4.0"
12
13
  },
13
14
  "devDependencies": {
14
15
  "jest": "^30.4.2"
15
16
  },
16
- "scripts": {
17
- "start": "node src/index.mjs",
18
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
19
- "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
20
- },
21
17
  "jest": {
22
18
  "testEnvironment": "node",
23
19
  "testMatch": [
@@ -25,5 +21,10 @@
25
21
  ]
26
22
  },
27
23
  "license": "MIT",
28
- "author": "Contributors"
24
+ "author": "Contributors",
25
+ "scripts": {
26
+ "start": "node src/index.mjs",
27
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
28
+ "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
29
+ }
29
30
  }
package/src/index.mjs CHANGED
@@ -15,6 +15,17 @@ import {
15
15
  resolveOtherPlaceholders,
16
16
  applyVarsToDocTemplate,
17
17
  wrapWithBanner,
18
+ resolveWorkspaceChildren,
19
+ excludeChildren,
20
+ buildPackageJsonGlob,
21
+ detectCicdType,
22
+ isAdvancedTemplatedFormat,
23
+ TEMPLATED_JSON_ADVANCED_VERSION,
24
+ extractGenerationCicd,
25
+ collectAssignedPackages,
26
+ listBuildTemplateFiles,
27
+ chooseAssignmentForNewPackage,
28
+ topologicalSort,
18
29
  } from './lib.mjs';
19
30
 
20
31
  const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
@@ -67,14 +78,17 @@ function resolveFolderName(pkgName, info = {}) {
67
78
  // ---------------------------------------------------------------------------
68
79
  // Expand using manifest.json "templated" section (new format)
69
80
  // ---------------------------------------------------------------------------
70
- function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options) {
81
+ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options, dependencies = {}) {
71
82
  return new Promise((resolve, reject) => {
72
83
  try {
73
- const packageNames = Object.keys(packageMap).filter(pkg => {
84
+ const filteredNames = Object.keys(packageMap).filter(pkg => {
74
85
  if (packageMap[pkg] === false) return false;
75
86
  const folderName = resolveFolderName(pkg, cicd[pkg] || {});
76
87
  return !options.exclude.includes(folderName);
77
88
  });
89
+ // Order so a package's local dependencies are generated before it,
90
+ // as much as possible (per manifest.json's "dependencies").
91
+ const packageNames = topologicalSort(filteredNames, dependencies);
78
92
 
79
93
  const allFolderNames = packageNames.map(pkg => resolveFolderName(pkg, cicd[pkg] || {}));
80
94
 
@@ -113,86 +127,198 @@ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallback
113
127
  }
114
128
 
115
129
  // ---------------------------------------------------------------------------
116
- // Expand using templated.json (legacy format: rootPath + template glob)
130
+ // Convert one legacy templated.json entry ({ rootPath, template }) into the
131
+ // advanced format: discover the packages currently under rootPath and assign
132
+ // each of them the entry's template, recording their folder/packageFolder.
117
133
  // ---------------------------------------------------------------------------
118
- function expandVarFromTemplatedJson(varname, jobConfig, templatePath, fallbackTemplatePath, options) {
119
- return new Promise((resolve, reject) => {
120
- const jobTemplate = readJobTemplate(jobConfig.template, templatePath, fallbackTemplatePath);
121
- const cwd = path.join(templatePath, jobConfig.rootPath);
122
- const search = buildSearchGlobs(options.exclude);
123
-
124
- globby(search, { cwd }).then((files) => {
125
- const folderBest = groupByFolder(files);
126
- const allNames = Object.keys(folderBest).sort().filter(n => !options.exclude.includes(n));
127
-
128
- const expandedJobs = allNames.map(name => {
129
- const content = readFileSync(path.join(cwd, folderBest[name].filePath), 'utf8');
130
- const ext = path.extname(folderBest[name].filePath).substring(1).toLowerCase();
131
- const fullName = parseManifestName(content, ext) || name;
132
- const safe = makeSafe(name);
133
- const packageName = resolvePackageName(fullName);
134
- const jobRoot = path.relative(process.cwd(), cwd);
135
- return expandJobTemplate(jobTemplate, { name, safe, packageName, fullName, jobRoot, metadata: {} });
136
- });
134
+ async function convertLegacyEntry(jobConfig, templatePath, options) {
135
+ const cwd = path.join(templatePath, jobConfig.rootPath);
136
+ const search = buildSearchGlobs(options.exclude);
137
+ const files = await globby(search, { cwd });
138
+
139
+ const folderBest = groupByFolder(files);
140
+ const allNames = Object.keys(folderBest).sort().filter(n => !options.exclude.includes(n));
141
+ const packageFolder = path.relative(process.cwd(), cwd);
142
+
143
+ const pkgMap = {};
144
+ const cicdEntries = {};
145
+ allNames.forEach(name => {
146
+ const content = readFileSync(path.join(cwd, folderBest[name].filePath), 'utf8');
147
+ const ext = path.extname(folderBest[name].filePath).substring(1).toLowerCase();
148
+ const fullName = parseManifestName(content, ext) || name;
149
+ pkgMap[fullName] = jobConfig.template;
150
+ cicdEntries[fullName] = { folderName: name, packageFolder };
151
+ });
137
152
 
138
- const jobs = resolveOtherPlaceholders(expandedJobs.join('\n'), allNames, cwd);
139
- resolve({ name: varname, text: jobs });
140
- }, reject);
153
+ return { pkgMap, cicdEntries };
154
+ }
155
+
156
+ // Convert a whole legacy-format templated.json document into the advanced
157
+ // { templated, cicd } shape.
158
+ async function convertLegacyTemplatedJson(rawConfig, templatePath, options) {
159
+ const templated = {};
160
+ const cicd = {};
161
+
162
+ for (const pipelineFile of Object.keys(rawConfig)) {
163
+ templated[pipelineFile] = {};
164
+ for (const varname of Object.keys(rawConfig[pipelineFile])) {
165
+ const { pkgMap, cicdEntries } = await convertLegacyEntry(rawConfig[pipelineFile][varname], templatePath, options);
166
+ templated[pipelineFile][varname] = pkgMap;
167
+ Object.assign(cicd, cicdEntries);
168
+ }
169
+ }
170
+
171
+ return { templated, cicd };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Discover packages not yet assigned to any template and add them, using
176
+ // detected package type + the existing convention (same packageFolder, else
177
+ // same output type, else the default "PACKAGES" placeholder). Mutates
178
+ // `templated`/`cicd` in place; returns true if anything was added.
179
+ // ---------------------------------------------------------------------------
180
+ async function detectAndAssignNewPackages(templated, cicd, options, templatePath) {
181
+ const assigned = collectAssignedPackages(templated);
182
+ const buildTemplateFiles = listBuildTemplateFiles(templatePath);
183
+
184
+ const pkgPaths = await globby(buildPackageJsonGlob(options.children), {
185
+ cwd: process.cwd(),
186
+ ignore: ['**/node_modules/**', '**/tests/**'],
187
+ absolute: true,
141
188
  });
189
+
190
+ let added = false;
191
+
192
+ pkgPaths.forEach(pkgPath => {
193
+ let pkg;
194
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { return; }
195
+ if (!pkg.name || !pkg.version || assigned.has(pkg.name)) return;
196
+
197
+ const folderName = path.basename(path.dirname(pkgPath));
198
+ if (options.exclude.includes(folderName)) return;
199
+
200
+ const packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
201
+ const detected = detectCicdType(pkg.name, pkg, pkgPath);
202
+ const { pipelineFile, placeholder, template } = chooseAssignmentForNewPackage({
203
+ packageFolder,
204
+ output: detected ? detected.output : undefined,
205
+ templated,
206
+ cicd,
207
+ buildTemplateFiles,
208
+ });
209
+
210
+ if (!template) return;
211
+
212
+ templated[pipelineFile] = templated[pipelineFile] || {};
213
+ templated[pipelineFile][placeholder] = templated[pipelineFile][placeholder] || {};
214
+ templated[pipelineFile][placeholder][pkg.name] = template;
215
+ cicd[pkg.name] = { ...(cicd[pkg.name] || {}), folderName, packageFolder };
216
+ assigned.add(pkg.name);
217
+ added = true;
218
+ });
219
+
220
+ return added;
142
221
  }
143
222
 
144
223
  // ---------------------------------------------------------------------------
145
224
  // main
146
225
  // ---------------------------------------------------------------------------
147
226
  async function main(options) {
227
+ // Detect workspace package roots (pnpm-workspace.yaml or package.json
228
+ // workspaces), same as monorepo-version and manifest. Used below to scope
229
+ // discovery of new (not-yet-configured) packages.
230
+ if (!options.children) {
231
+ const detected = resolveWorkspaceChildren(process.cwd());
232
+ if (detected) {
233
+ if (options.debug) console.log(`Auto-detected workspace children: ${detected}`);
234
+ options.children = detected;
235
+ }
236
+ }
237
+
238
+ // --exclude drops specific root directories from whichever children list is
239
+ // in effect, auto-detected or explicit. The same list is also used below to
240
+ // filter individual packages by folder name during generation.
241
+ if (options.exclude.length > 0) {
242
+ options.children = excludeChildren(options.children, options.exclude) || options.children;
243
+ }
244
+
148
245
  const configDir = options.platform === 'github' ? '.github' : '.cicd';
149
246
  const altConfigDir = options.platform === 'github' ? '.cicd' : '.github';
150
247
  const templatePath = path.join(process.cwd(), `${configDir}/templates`);
151
248
  const fallbackTemplatePath = path.join(process.cwd(), `${altConfigDir}/templates`);
152
249
 
153
- // Try manifest.json first, then fall back to templated.json
154
250
  const manifestPath = path.join(process.cwd(), `${configDir}/manifest.json`);
155
251
  const templatedJsonPath = path.join(process.cwd(), `${configDir}/templates/templated.json`);
156
252
 
157
- const useManifest = existsSync(manifestPath);
158
- const useTemplatedJson = !useManifest && existsSync(templatedJsonPath);
159
-
160
- if (!useManifest && !useTemplatedJson) {
161
- throw `enablement-build-template-generate: could not find manifest.json at ${configDir}/manifest.json or templated.json at ${configDir}/templates/templated.json`;
253
+ // ── Load config: manifest.json's "templated" section (if still present, to
254
+ // be migrated out), else templated.json (new or legacy format). Whatever we
255
+ // load is normalized into { templated, cicd } once, up front, so generation
256
+ // below always runs the same (advanced-format) code path.
257
+ let templated;
258
+ let cicd;
259
+ let manifestToPersist = null; // set when manifest.json needs its "templated" section stripped
260
+ let templatedJsonDirty = false; // set when templated.json needs (re)writing
261
+ let dependencies = {}; // manifest.json's local dependency graph, for ordering generation
262
+
263
+ if (existsSync(manifestPath)) {
264
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8') || '{}');
265
+ dependencies = manifest.dependencies || {};
266
+ if (isAdvancedTemplatedFormat(manifest)) {
267
+ templated = manifest.templated;
268
+ cicd = extractGenerationCicd(manifest.cicd || {});
269
+ delete manifest.templated;
270
+ manifestToPersist = manifest;
271
+ templatedJsonDirty = true;
272
+ }
162
273
  }
163
274
 
164
- let config;
165
- let cicd = {};
275
+ if (!templated) {
276
+ if (!existsSync(templatedJsonPath)) {
277
+ throw `enablement-build-template-generate: could not find manifest.json "templated" section at ${configDir}/manifest.json or templated.json at ${configDir}/templates/templated.json`;
278
+ }
166
279
 
167
- if (useManifest) {
168
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
169
- if (!manifest.templated) {
170
- throw `enablement-build-template-generate: manifest.json has no "templated" section`;
280
+ const raw = JSON.parse(readFileSync(templatedJsonPath, 'utf8') || '{}');
281
+ if (isAdvancedTemplatedFormat(raw)) {
282
+ templated = raw.templated;
283
+ cicd = raw.cicd || {};
284
+ if (typeof raw.version !== 'number') {
285
+ // Advanced format but predates the version field: write it back once,
286
+ // purely to backfill it, even if nothing else changed.
287
+ templatedJsonDirty = true;
288
+ }
289
+ } else {
290
+ const converted = await convertLegacyTemplatedJson(raw, templatePath, options);
291
+ templated = converted.templated;
292
+ cicd = converted.cicd;
293
+ templatedJsonDirty = true;
171
294
  }
172
- config = manifest.templated;
173
- cicd = manifest.cicd || {};
174
- } else {
175
- config = JSON.parse(readFileSync(templatedJsonPath, 'utf8'));
176
295
  }
177
296
 
297
+ // ── Discover packages not yet assigned to any template and add them.
298
+ if (await detectAndAssignNewPackages(templated, cicd, options, templatePath)) {
299
+ templatedJsonDirty = true;
300
+ }
301
+
302
+ // ── Persist. At most one write to each file, only when something changed.
303
+ if (manifestToPersist) {
304
+ writeFileSync(manifestPath, JSON.stringify(manifestToPersist, null, 2), 'utf8');
305
+ }
306
+ if (templatedJsonDirty) {
307
+ writeFileSync(templatedJsonPath, JSON.stringify({ version: TEMPLATED_JSON_ADVANCED_VERSION, templated, cicd }, null, 2), 'utf8');
308
+ }
309
+
310
+ // ── Generate.
178
311
  const plist = [];
179
312
 
180
- Object.keys(config).forEach((templateFilename) => {
313
+ Object.keys(templated).forEach((templateFilename) => {
181
314
  plist.push(new Promise((primaryResolve, reject) => {
182
315
  try {
183
316
  const docTemplate = readFileSync(path.join(templatePath, templateFilename), 'utf8');
184
317
  const jlist = [];
185
318
 
186
- Object.keys(config[templateFilename]).forEach((varname) => {
187
- const varConfig = config[templateFilename][varname];
188
-
189
- if (useManifest) {
190
- // New format: varConfig is { pkgName: "template.yml" | [...] }
191
- jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options));
192
- } else {
193
- // Legacy format: varConfig is { rootPath, template, exclude? }
194
- jlist.push(expandVarFromTemplatedJson(varname, varConfig, templatePath, fallbackTemplatePath, options));
195
- }
319
+ Object.keys(templated[templateFilename]).forEach((varname) => {
320
+ const varConfig = templated[templateFilename][varname];
321
+ jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options, dependencies));
196
322
  });
197
323
 
198
324
  Promise.all(jlist).then((results) => {
@@ -220,9 +346,10 @@ async function main(options) {
220
346
  // CLI
221
347
  // ---------------------------------------------------------------------------
222
348
  let options = {
223
- platform: null,
349
+ platform: 'ado',
224
350
  exclude: [],
225
351
  debug: false,
352
+ children: null,
226
353
  };
227
354
 
228
355
  let argv = process.argv;
@@ -243,20 +370,6 @@ for (let i = 2; i < argv.length; i++) {
243
370
  }
244
371
  }
245
372
 
246
- if (!options.platform) {
247
- const hasGithubManifest = existsSync(path.join(process.cwd(), '.github/manifest.json'));
248
- const hasAdoManifest = existsSync(path.join(process.cwd(), '.cicd/manifest.json'));
249
- const hasGithubTemplated = existsSync(path.join(process.cwd(), '.github/templates/templated.json'));
250
- const hasAdoTemplated = existsSync(path.join(process.cwd(), '.cicd/templates/templated.json'));
251
-
252
- if (hasGithubManifest || hasGithubTemplated) {
253
- options.platform = 'github';
254
- } else if (hasAdoManifest || hasAdoTemplated) {
255
- options.platform = 'ado';
256
- } else {
257
- options.platform = 'ado';
258
- }
259
- }
260
373
 
261
374
  (async () => {
262
375
  try {
package/src/lib.mjs CHANGED
@@ -1,4 +1,135 @@
1
1
  import path from 'path';
2
+ import { readdirSync } from 'fs';
3
+ import { detectCicdType } from 'enablement-build-manifest/src/detect.mjs';
4
+ import { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren } from 'enablement-build-manifest/src/workspace.mjs';
5
+
6
+ // Re-exposes the manifest package's pure package-type detector (output/language/
7
+ // tier/packageType from package.json shape or csproj/pyproject.toml/Dockerfile)
8
+ // so template-generate can classify a package without needing manifest.json.
9
+ export { detectCicdType };
10
+
11
+ // Re-exposes the manifest package's workspace root-directory detector
12
+ // (pnpm-workspace.yaml or package.json#workspaces), the exclude filter for it,
13
+ // and the glob it implies, so template-generate can find package roots
14
+ // without needing manifest.json.
15
+ export { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren };
16
+
17
+ // Re-exposes the manifest package's dependency-order sort, so a package's
18
+ // local dependencies (per manifest.json's "dependencies") are generated
19
+ // before it, as much as possible.
20
+ export { topologicalSort } from 'enablement-build-manifest/src/dependencyOrder.mjs';
21
+
22
+ // ─── templated.json format helpers ───────────────────────────────────────────
23
+
24
+ // templated.json schema versions. 1 = legacy rootPath+template shape, 2 = the
25
+ // advanced { templated, cicd } shape. Every write stamps ADVANCED_VERSION;
26
+ // LEGACY_VERSION is only ever inferred for older, unversioned files.
27
+ export const TEMPLATED_JSON_LEGACY_VERSION = 1;
28
+ export const TEMPLATED_JSON_ADVANCED_VERSION = 2;
29
+
30
+ // True when parsed templated.json content is in the advanced format. An
31
+ // explicit "version" property is authoritative and used exclusively — no
32
+ // shape-sniffing once it's present. Only when "version" is absent do we infer
33
+ // it from the legacy content's shape (a top-level "templated" key = advanced).
34
+ export function isAdvancedTemplatedFormat(data) {
35
+ if (!data) return false;
36
+ if (typeof data.version === 'number') return data.version >= TEMPLATED_JSON_ADVANCED_VERSION;
37
+ return !!data.templated;
38
+ }
39
+
40
+ // Pick only the per-package fields generation needs (folderName, packageFolder,
41
+ // metadata) out of a fuller manifest.json cicd map, dropping unrelated fields
42
+ // like output/language/tier/packageType/hash/version.
43
+ export function extractGenerationCicd(cicd = {}) {
44
+ const result = {};
45
+ Object.keys(cicd).forEach((pkgName) => {
46
+ const entry = cicd[pkgName] || {};
47
+ const picked = {};
48
+ if (entry.folderName) picked.folderName = entry.folderName;
49
+ if (entry.packageFolder) picked.packageFolder = entry.packageFolder;
50
+ if (entry.metadata) picked.metadata = entry.metadata;
51
+ result[pkgName] = picked;
52
+ });
53
+ return result;
54
+ }
55
+
56
+ // Collect every package name already assigned to a template anywhere in the
57
+ // advanced-format templated map, across all pipeline files and placeholders.
58
+ export function collectAssignedPackages(templated = {}) {
59
+ const names = new Set();
60
+ Object.values(templated).forEach((placeholders) => {
61
+ Object.values(placeholders || {}).forEach((pkgMap) => {
62
+ Object.keys(pkgMap || {}).forEach((name) => names.add(name));
63
+ });
64
+ });
65
+ return names;
66
+ }
67
+
68
+ // List "build" template files: underscore-prefixed .yml files in the templates
69
+ // dir that aren't scan/check templates. Mirrors manifest.mjs's prior convention
70
+ // for choosing a template by detected package type.
71
+ export function listBuildTemplateFiles(templatePath) {
72
+ let files;
73
+ try {
74
+ files = readdirSync(templatePath);
75
+ } catch {
76
+ return [];
77
+ }
78
+ const withUnderscore = files.filter((f) => f.endsWith('.yml') && f.includes('_'));
79
+ return withUnderscore.filter((f) => !f.includes('scan') && !f.includes('check'));
80
+ }
81
+
82
+ // Map a detected cicd "output" value to the keyword used to pick a build template.
83
+ function templateKeywordForOutput(output) {
84
+ if (output === 'pypi') return 'python';
85
+ if (output === 'nuget') return 'dotnet';
86
+ if (output === 'docker') return 'docker';
87
+ return 'node';
88
+ }
89
+
90
+ // Derive the best-fit build template for a detected output type.
91
+ export function deriveTemplateForType(output, buildTemplateFiles) {
92
+ const lookfor = templateKeywordForOutput(output);
93
+ for (const file of buildTemplateFiles) {
94
+ if (file.includes(lookfor)) return file;
95
+ }
96
+ return buildTemplateFiles[0] || null;
97
+ }
98
+
99
+ // Choose where a newly-discovered package should be assigned, mirroring
100
+ // manifest.mjs's prior convention:
101
+ // 1. reuse the placeholder of an existing package under the same packageFolder
102
+ // 2. else reuse the placeholder of an existing package of the same output type
103
+ // 3. else fall back to the default "PACKAGES" placeholder on the first pipeline file
104
+ export function chooseAssignmentForNewPackage({ packageFolder, output, templated, cicd, buildTemplateFiles }) {
105
+ const pipelineFiles = Object.keys(templated);
106
+ const primaryPipelineFile = pipelineFiles[0] || 'build.yml';
107
+
108
+ for (const pipelineFile of pipelineFiles) {
109
+ for (const [placeholder, pkgMap] of Object.entries(templated[pipelineFile] || {})) {
110
+ for (const existingName of Object.keys(pkgMap || {})) {
111
+ if ((cicd[existingName] || {}).packageFolder === packageFolder) {
112
+ return { pipelineFile, placeholder, template: pkgMap[existingName] };
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ const lookfor = templateKeywordForOutput(output);
119
+ for (const [placeholder, pkgMap] of Object.entries(templated[primaryPipelineFile] || {})) {
120
+ for (const tpl of Object.values(pkgMap || {})) {
121
+ if (typeof tpl === 'string' && tpl.toLowerCase().includes(lookfor)) {
122
+ return { pipelineFile: primaryPipelineFile, placeholder, template: tpl };
123
+ }
124
+ }
125
+ }
126
+
127
+ return {
128
+ pipelineFile: primaryPipelineFile,
129
+ placeholder: 'PACKAGES',
130
+ template: deriveTemplateForType(output, buildTemplateFiles),
131
+ };
132
+ }
2
133
 
3
134
  // Priority order for version manifest files (lower index = higher priority)
4
135
  export const MANIFEST_PRIORITY = { 'package.json': 0, 'version.json': 1, 'pyproject.toml': 2 };
@@ -88,9 +88,9 @@ function createFixtureWorkspace(mode, options = {}) {
88
88
  return tmpRoot;
89
89
  }
90
90
 
91
- function runGenerator(cwd) {
91
+ function runGenerator(cwd, extraArgs = []) {
92
92
  const scriptPath = fileURLToPath(new URL('../src/index.mjs', import.meta.url));
93
- execFileSync(process.execPath, [scriptPath, '--platform', 'ado'], {
93
+ execFileSync(process.execPath, [scriptPath, '--platform', 'ado', ...extraArgs], {
94
94
  cwd,
95
95
  stdio: 'pipe',
96
96
  });
@@ -183,3 +183,363 @@ describe('template-generate mode parity', () => {
183
183
  }
184
184
  });
185
185
  });
186
+
187
+ describe('templated.json migration', () => {
188
+ it("migrates manifest.json's templated section into templated.json and strips it from manifest.json", () => {
189
+ const root = createFixtureWorkspace('manifest');
190
+ try {
191
+ runGenerator(root);
192
+
193
+ const manifest = JSON.parse(readFileSync(path.join(root, '.cicd', 'manifest.json'), 'utf8'));
194
+ expect(manifest.templated).toBeUndefined();
195
+
196
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
197
+ expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
198
+ expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
199
+ expect(templatedJson.cicd['@scope/enablement-build-alpha-lib'].packageFolder).toBe('packages');
200
+ expect(templatedJson.version).toBe(2);
201
+ } finally {
202
+ rmSync(root, { recursive: true, force: true });
203
+ }
204
+ });
205
+
206
+ it('converts a legacy templated.json to the new format on disk', () => {
207
+ const root = createFixtureWorkspace('legacy');
208
+ try {
209
+ runGenerator(root);
210
+
211
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
212
+ expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
213
+ expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
214
+ expect(templatedJson.cicd['@scope/enablement-build-alpha-lib']).toEqual({ folderName: 'alpha-lib', packageFolder: 'packages' });
215
+ expect(templatedJson.cicd['enablement-build-beta-tool']).toEqual({ folderName: 'beta-tool', packageFolder: 'packages' });
216
+ expect(templatedJson.version).toBe(2);
217
+ } finally {
218
+ rmSync(root, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ it('backfills the version field on an unversioned advanced-format templated.json, even when nothing else changed', () => {
223
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
224
+ try {
225
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
226
+ mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
227
+
228
+ writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
229
+ name: 'existing-lib',
230
+ version: '1.0.0',
231
+ });
232
+
233
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
234
+ writeFileSync(
235
+ path.join(root, '.cicd', 'templates', 'job.yml'),
236
+ '- job: build_{safe}\n displayName: NAME={name}\n',
237
+ 'utf8'
238
+ );
239
+
240
+ // Advanced format already, but predates the version field.
241
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
242
+ templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
243
+ cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
244
+ });
245
+
246
+ const firstOut = runGenerator(root);
247
+ const templatedAfter = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
248
+
249
+ expect(templatedAfter.version).toBe(2);
250
+ expect(templatedAfter.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
251
+
252
+ // Running again is now a true no-op: same output, same file.
253
+ const secondOut = runGenerator(root);
254
+ const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
255
+ expect(secondOut).toBe(firstOut);
256
+ expect(templatedAfterSecond).toEqual(templatedAfter);
257
+ } finally {
258
+ rmSync(root, { recursive: true, force: true });
259
+ }
260
+ });
261
+
262
+ it('detects a new package, assigns it a template by type, and leaves existing packages untouched', () => {
263
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
264
+ try {
265
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
266
+ mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
267
+ mkdirSync(path.join(root, 'apps', 'new-svc'), { recursive: true });
268
+
269
+ writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
270
+ name: 'existing-lib',
271
+ version: '1.0.0',
272
+ });
273
+ writeJson(path.join(root, 'apps', 'new-svc', 'package.json'), {
274
+ name: 'new-svc',
275
+ version: '1.0.0',
276
+ main: 'index.js',
277
+ });
278
+
279
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
280
+ writeFileSync(
281
+ path.join(root, '.cicd', 'templates', 'job.yml'),
282
+ '- job: build_{safe}\n displayName: NAME={name}\n',
283
+ 'utf8'
284
+ );
285
+ writeFileSync(
286
+ path.join(root, '.cicd', 'templates', '_build_node_package.yml'),
287
+ '- job: node_{safe}\n displayName: NAME={name}\n',
288
+ 'utf8'
289
+ );
290
+
291
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
292
+ templated: {
293
+ 'build.yml': {
294
+ PACKAGES: { 'existing-lib': 'job.yml' },
295
+ },
296
+ },
297
+ cicd: {
298
+ 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' },
299
+ },
300
+ });
301
+
302
+ const firstOut = runGenerator(root);
303
+ const templatedAfterFirst = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
304
+
305
+ // Existing package untouched.
306
+ expect(templatedAfterFirst.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
307
+ expect(templatedAfterFirst.cicd['existing-lib']).toEqual({ folderName: 'existing-lib', packageFolder: 'packages' });
308
+
309
+ // New package detected, classified as a node package, and assigned the node build template.
310
+ expect(templatedAfterFirst.templated['build.yml'].PACKAGES['new-svc']).toBe('_build_node_package.yml');
311
+ expect(templatedAfterFirst.cicd['new-svc']).toEqual({ folderName: 'new-svc', packageFolder: 'apps' });
312
+
313
+ expect(firstOut).toContain('NAME=existing-lib');
314
+ expect(firstOut).toContain('NAME=new-svc');
315
+
316
+ // Second run: nothing new to detect, output and config are stable.
317
+ const secondOut = runGenerator(root);
318
+ const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
319
+
320
+ expect(secondOut).toBe(firstOut);
321
+ expect(templatedAfterSecond).toEqual(templatedAfterFirst);
322
+ } finally {
323
+ rmSync(root, { recursive: true, force: true });
324
+ }
325
+ });
326
+ });
327
+
328
+ describe('template-generate new-package assignment convention', () => {
329
+ it('reuses the placeholder/template of an existing package under the same packageFolder (tier 1)', () => {
330
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
331
+ try {
332
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
333
+ mkdirSync(path.join(root, 'packages', 'new-a'), { recursive: true });
334
+
335
+ writeJson(path.join(root, 'packages', 'new-a', 'package.json'), { name: 'new-a', version: '1.0.0', main: 'index.js' });
336
+
337
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
338
+ writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
339
+ writeFileSync(path.join(root, '.cicd', 'templates', 'custom.yml'), '- job: custom_{safe}\n', 'utf8');
340
+ // No "_build_*" template files exist at all — if this fell through to the
341
+ // type-derived fallback, there'd be nothing to assign and new-a would be skipped.
342
+
343
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
344
+ templated: { 'build.yml': { PACKAGES: { 'existing-a': 'custom.yml' } } },
345
+ cicd: { 'existing-a': { folderName: 'existing-a', packageFolder: 'packages' } },
346
+ });
347
+
348
+ const out = runGenerator(root, ['--children', 'packages']);
349
+
350
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
351
+ expect(templatedJson.templated['build.yml'].PACKAGES['new-a']).toBe('custom.yml');
352
+ expect(templatedJson.cicd['new-a']).toEqual({ folderName: 'new-a', packageFolder: 'packages' });
353
+ expect(out).toContain('custom_newa');
354
+ } finally {
355
+ rmSync(root, { recursive: true, force: true });
356
+ }
357
+ });
358
+
359
+ it('reuses the placeholder/template of an existing package of the same output type when packageFolder differs (tier 2)', () => {
360
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
361
+ try {
362
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
363
+ mkdirSync(path.join(root, 'apps', 'new-node-app'), { recursive: true });
364
+
365
+ writeJson(path.join(root, 'apps', 'new-node-app', 'package.json'), { name: 'new-node-app', version: '1.0.0', main: 'index.js' });
366
+
367
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n{SERVICES}\n', 'utf8');
368
+ writeFileSync(path.join(root, '.cicd', 'templates', '_build_node_package.yml'), '- job: node_{safe}\n', 'utf8');
369
+
370
+ // existing-node-lib lives under a different packageFolder ("services"),
371
+ // so tier 1 (same packageFolder) cannot match new-node-app ("apps").
372
+ // It's assigned a template containing "node" under a non-default
373
+ // placeholder ("SERVICES"), which tier 2 should reuse verbatim.
374
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
375
+ templated: {
376
+ 'build.yml': {
377
+ PACKAGES: {},
378
+ SERVICES: { 'existing-node-lib': '_build_node_package.yml' },
379
+ },
380
+ },
381
+ cicd: {
382
+ 'existing-node-lib': { folderName: 'existing-node-lib', packageFolder: 'services' },
383
+ },
384
+ });
385
+
386
+ runGenerator(root, ['--children', 'apps']);
387
+
388
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
389
+ // Landed in SERVICES (existing-node-lib's placeholder), not the PACKAGES
390
+ // default — proving reuse-by-type (tier 2), not the type-derived fallback (tier 3).
391
+ expect(templatedJson.templated['build.yml'].SERVICES['new-node-app']).toBe('_build_node_package.yml');
392
+ expect(templatedJson.templated['build.yml'].PACKAGES['new-node-app']).toBeUndefined();
393
+ } finally {
394
+ rmSync(root, { recursive: true, force: true });
395
+ }
396
+ });
397
+ });
398
+
399
+ describe('template-generate --children', () => {
400
+ it('scopes new-package scanning to an explicit --children list, overriding the broad-scan default', () => {
401
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
402
+ try {
403
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
404
+ mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
405
+ mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
406
+
407
+ // No pnpm-workspace.yaml / package.json#workspaces declared at all, so
408
+ // without --children this would broad-scan and find "tools/skip-me" too.
409
+ writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
410
+ writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
411
+
412
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
413
+ writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
414
+
415
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
416
+ templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
417
+ cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
418
+ });
419
+
420
+ runGenerator(root, ['--children', 'packages']);
421
+
422
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
423
+ expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
424
+ expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
425
+ expect(templatedJson.cicd['skip-me']).toBeUndefined();
426
+ } finally {
427
+ rmSync(root, { recursive: true, force: true });
428
+ }
429
+ });
430
+ });
431
+
432
+ describe('template-generate --exclude', () => {
433
+ it('drops an excluded root directory from auto-detected new-package scanning', () => {
434
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
435
+ try {
436
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
437
+ mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
438
+ mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
439
+
440
+ writeJson(path.join(root, 'package.json'), {
441
+ name: 'tmp-monorepo',
442
+ private: true,
443
+ workspaces: ['packages/*', 'tools/*'],
444
+ });
445
+ writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
446
+ writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
447
+
448
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
449
+ writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
450
+
451
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
452
+ templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
453
+ cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
454
+ });
455
+
456
+ runGenerator(root, ['--exclude', 'tools']);
457
+
458
+ const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
459
+ expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
460
+ expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
461
+ expect(templatedJson.cicd['skip-me']).toBeUndefined();
462
+ } finally {
463
+ rmSync(root, { recursive: true, force: true });
464
+ }
465
+ });
466
+ });
467
+
468
+ describe('template-generate dependency ordering', () => {
469
+ it('generates a dependency before its dependent, per manifest.json dependencies', () => {
470
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
471
+ try {
472
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
473
+
474
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
475
+ writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
476
+
477
+ // Assignment order deliberately does not match dependency order:
478
+ // pkg-a depends on pkg-b depends on pkg-c, but keys are alphabetical.
479
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
480
+ templated: {
481
+ 'build.yml': {
482
+ PACKAGES: { 'pkg-a': 'job.yml', 'pkg-b': 'job.yml', 'pkg-c': 'job.yml' },
483
+ },
484
+ },
485
+ cicd: {
486
+ 'pkg-a': { folderName: 'pkg-a', packageFolder: 'packages' },
487
+ 'pkg-b': { folderName: 'pkg-b', packageFolder: 'packages' },
488
+ 'pkg-c': { folderName: 'pkg-c', packageFolder: 'packages' },
489
+ },
490
+ });
491
+
492
+ // manifest.json here only supplies the dependency graph (no "templated").
493
+ writeJson(path.join(root, '.cicd', 'manifest.json'), {
494
+ dependencies: {
495
+ 'pkg-a': ['pkg-b'],
496
+ 'pkg-b': ['pkg-c'],
497
+ 'pkg-c': [],
498
+ },
499
+ });
500
+
501
+ const out = runGenerator(root);
502
+
503
+ const posA = out.indexOf('build_pkga');
504
+ const posB = out.indexOf('build_pkgb');
505
+ const posC = out.indexOf('build_pkgc');
506
+
507
+ expect(posC).toBeLessThan(posB);
508
+ expect(posB).toBeLessThan(posA);
509
+ } finally {
510
+ rmSync(root, { recursive: true, force: true });
511
+ }
512
+ });
513
+ });
514
+
515
+ describe('template-generate --exclude (generation-side)', () => {
516
+ it('omits an already-assigned package from the generated output by folder name', () => {
517
+ const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
518
+ try {
519
+ mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
520
+
521
+ writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
522
+ writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
523
+
524
+ writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
525
+ version: 2,
526
+ templated: {
527
+ 'build.yml': {
528
+ PACKAGES: { 'keep-me': 'job.yml', 'drop-me': 'job.yml' },
529
+ },
530
+ },
531
+ cicd: {
532
+ 'keep-me': { folderName: 'keep-me', packageFolder: 'packages' },
533
+ 'drop-me': { folderName: 'drop-me', packageFolder: 'packages' },
534
+ },
535
+ });
536
+
537
+ const out = runGenerator(root, ['--exclude', 'drop-me']);
538
+
539
+ expect(out).toContain('build_keepme');
540
+ expect(out).not.toContain('build_dropme');
541
+ } finally {
542
+ rmSync(root, { recursive: true, force: true });
543
+ }
544
+ });
545
+ });
@@ -1,4 +1,7 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
2
5
  import {
3
6
  MANIFEST_PRIORITY,
4
7
  stripWhitespace,
@@ -12,6 +15,12 @@ import {
12
15
  resolveOtherPlaceholders,
13
16
  applyVarsToDocTemplate,
14
17
  wrapWithBanner,
18
+ isAdvancedTemplatedFormat,
19
+ extractGenerationCicd,
20
+ collectAssignedPackages,
21
+ listBuildTemplateFiles,
22
+ deriveTemplateForType,
23
+ chooseAssignmentForNewPackage,
15
24
  } from '../src/lib.mjs';
16
25
 
17
26
  // ─── MANIFEST_PRIORITY ────────────────────────────────────────────────────────
@@ -354,3 +363,202 @@ describe('wrapWithBanner', () => {
354
363
  expect(result).toContain('\n\nbody\n\n');
355
364
  });
356
365
  });
366
+
367
+ // ─── isAdvancedTemplatedFormat ────────────────────────────────────────────────
368
+
369
+ describe('isAdvancedTemplatedFormat', () => {
370
+ it('is true when a top-level "templated" key is present', () => {
371
+ expect(isAdvancedTemplatedFormat({ templated: {}, cicd: {} })).toBe(true);
372
+ });
373
+
374
+ it('is false for the legacy rootPath+template shape', () => {
375
+ expect(isAdvancedTemplatedFormat({ 'build.yml': { PACKAGES: { rootPath: '../packages', template: 'job.yml' } } })).toBe(false);
376
+ });
377
+
378
+ it('is false for null/undefined', () => {
379
+ expect(isAdvancedTemplatedFormat(null)).toBe(false);
380
+ expect(isAdvancedTemplatedFormat(undefined)).toBe(false);
381
+ });
382
+
383
+ it('uses an explicit version property exclusively, ignoring shape', () => {
384
+ // version says legacy even though a "templated" key is present.
385
+ expect(isAdvancedTemplatedFormat({ version: 1, templated: {} })).toBe(false);
386
+ // version says advanced even though the shape looks legacy.
387
+ expect(isAdvancedTemplatedFormat({ version: 2, 'build.yml': { PACKAGES: { rootPath: '../packages', template: 'job.yml' } } })).toBe(true);
388
+ });
389
+ });
390
+
391
+ // ─── extractGenerationCicd ────────────────────────────────────────────────────
392
+
393
+ describe('extractGenerationCicd', () => {
394
+ it('keeps only folderName, packageFolder, and metadata', () => {
395
+ const result = extractGenerationCicd({
396
+ 'pkg-a': {
397
+ output: 'npm',
398
+ language: 'JavaScript',
399
+ tier: 'component',
400
+ packageFolder: 'packages',
401
+ folderName: 'pkg-a',
402
+ metadata: { owner: 'team-a' },
403
+ hash: 'abc123',
404
+ version: '1.0.0',
405
+ },
406
+ });
407
+
408
+ expect(result).toEqual({
409
+ 'pkg-a': {
410
+ packageFolder: 'packages',
411
+ folderName: 'pkg-a',
412
+ metadata: { owner: 'team-a' },
413
+ },
414
+ });
415
+ });
416
+
417
+ it('omits fields that are absent rather than including them as undefined', () => {
418
+ const result = extractGenerationCicd({ 'pkg-a': { output: 'npm' } });
419
+ expect(result['pkg-a']).toEqual({});
420
+ expect(Object.keys(result['pkg-a'])).toEqual([]);
421
+ });
422
+
423
+ it('handles an empty or missing map', () => {
424
+ expect(extractGenerationCicd({})).toEqual({});
425
+ expect(extractGenerationCicd()).toEqual({});
426
+ });
427
+ });
428
+
429
+ // ─── collectAssignedPackages ──────────────────────────────────────────────────
430
+
431
+ describe('collectAssignedPackages', () => {
432
+ it('collects package names across pipeline files and placeholders', () => {
433
+ const result = collectAssignedPackages({
434
+ 'build.yml': {
435
+ PACKAGES: { 'pkg-a': 'job.yml', 'pkg-b': 'job.yml' },
436
+ OTHER: { 'pkg-c': 'job2.yml' },
437
+ },
438
+ 'scan.yml': {
439
+ PACKAGES: { 'pkg-a': 'scan.yml' },
440
+ },
441
+ });
442
+
443
+ expect(result).toEqual(new Set(['pkg-a', 'pkg-b', 'pkg-c']));
444
+ });
445
+
446
+ it('returns an empty set for an empty map', () => {
447
+ expect(collectAssignedPackages({})).toEqual(new Set());
448
+ });
449
+ });
450
+
451
+ // ─── listBuildTemplateFiles ───────────────────────────────────────────────────
452
+
453
+ describe('listBuildTemplateFiles', () => {
454
+ function tmpTemplatesDir(files) {
455
+ const root = mkdtempSync(path.join(os.tmpdir(), 'lib-tests-'));
456
+ mkdirSync(path.join(root, 'templates'), { recursive: true });
457
+ for (const f of files) writeFileSync(path.join(root, 'templates', f), '', 'utf8');
458
+ return path.join(root, 'templates');
459
+ }
460
+
461
+ it('keeps underscore-prefixed build templates, excluding scan/check', () => {
462
+ const dir = tmpTemplatesDir([
463
+ '_build_node_package.yml',
464
+ '_build_dotnet_package.yml',
465
+ '_scan_node.yml',
466
+ '_check_code.yml',
467
+ 'build.yml',
468
+ 'notes.txt',
469
+ ]);
470
+ try {
471
+ expect(listBuildTemplateFiles(dir).sort()).toEqual(['_build_dotnet_package.yml', '_build_node_package.yml']);
472
+ } finally {
473
+ rmSync(path.dirname(dir), { recursive: true, force: true });
474
+ }
475
+ });
476
+
477
+ it('returns an empty array when the templates dir does not exist', () => {
478
+ expect(listBuildTemplateFiles('/does/not/exist')).toEqual([]);
479
+ });
480
+ });
481
+
482
+ // ─── deriveTemplateForType ────────────────────────────────────────────────────
483
+
484
+ describe('deriveTemplateForType', () => {
485
+ const files = ['_build_node_package.yml', '_build_python_package.yml', '_build_dotnet_package.yml', '_build_docker_package.yml'];
486
+
487
+ it('picks the matching build template per output type', () => {
488
+ expect(deriveTemplateForType('npm', files)).toBe('_build_node_package.yml');
489
+ expect(deriveTemplateForType('pypi', files)).toBe('_build_python_package.yml');
490
+ expect(deriveTemplateForType('nuget', files)).toBe('_build_dotnet_package.yml');
491
+ expect(deriveTemplateForType('docker', files)).toBe('_build_docker_package.yml');
492
+ });
493
+
494
+ it('falls back to the first build template for an unknown output type', () => {
495
+ expect(deriveTemplateForType('something-else', ['_build_dotnet_package.yml'])).toBe('_build_dotnet_package.yml');
496
+ });
497
+
498
+ it('returns null when there are no build templates', () => {
499
+ expect(deriveTemplateForType('npm', [])).toBeNull();
500
+ });
501
+ });
502
+
503
+ // ─── chooseAssignmentForNewPackage ────────────────────────────────────────────
504
+
505
+ describe('chooseAssignmentForNewPackage', () => {
506
+ const buildTemplateFiles = ['_build_node_package.yml', '_build_python_package.yml'];
507
+
508
+ it('reuses the placeholder of an existing package under the same packageFolder', () => {
509
+ const templated = { 'build.yml': { PACKAGES: { 'pkg-a': 'job.yml' } } };
510
+ const cicd = { 'pkg-a': { packageFolder: 'packages' } };
511
+
512
+ const result = chooseAssignmentForNewPackage({
513
+ packageFolder: 'packages',
514
+ output: 'pypi',
515
+ templated,
516
+ cicd,
517
+ buildTemplateFiles,
518
+ });
519
+
520
+ expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: 'job.yml' });
521
+ });
522
+
523
+ it('reuses the placeholder of a package with the same output type when no folder match exists', () => {
524
+ const templated = { 'build.yml': { NODE_JOBS: { 'pkg-a': '_build_node_package.yml' } } };
525
+ const cicd = { 'pkg-a': { packageFolder: 'apps' } };
526
+
527
+ const result = chooseAssignmentForNewPackage({
528
+ packageFolder: 'packages',
529
+ output: 'npm',
530
+ templated,
531
+ cicd,
532
+ buildTemplateFiles,
533
+ });
534
+
535
+ expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'NODE_JOBS', template: '_build_node_package.yml' });
536
+ });
537
+
538
+ it('falls back to the default PACKAGES placeholder with a type-derived template', () => {
539
+ const templated = { 'build.yml': {} };
540
+ const cicd = {};
541
+
542
+ const result = chooseAssignmentForNewPackage({
543
+ packageFolder: 'packages',
544
+ output: 'pypi',
545
+ templated,
546
+ cicd,
547
+ buildTemplateFiles,
548
+ });
549
+
550
+ expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: '_build_python_package.yml' });
551
+ });
552
+
553
+ it('defaults to build.yml when there is no pipeline file yet', () => {
554
+ const result = chooseAssignmentForNewPackage({
555
+ packageFolder: 'packages',
556
+ output: 'npm',
557
+ templated: {},
558
+ cicd: {},
559
+ buildTemplateFiles,
560
+ });
561
+
562
+ expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: '_build_node_package.yml' });
563
+ });
564
+ });