enablement-build-manifest 1.0.4 → 1.4.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
@@ -10,24 +10,21 @@ npx enablement-build-manifest [options]
10
10
 
11
11
  Run from the root of your monorepo. The manifest is printed to stdout and optionally saved to disk.
12
12
 
13
- When using `--save`, existing `templated` package mappings and existing `cicd` hash/version fields in the target manifest are preserved.
13
+ Every `--save` run converts the manifest to its current schema version: any template routing still recorded in the manifest is moved to `{configDir}/templates/templated.json`, any `cicd` hash values are moved to `{configDir}/hash.json`, and neither is kept in `manifest.json` afterward. `cicd` version fields are still preserved.
14
14
 
15
15
  ## Options
16
16
 
17
17
  | Flag | Description |
18
18
  |------|-------------|
19
19
  | `--save` | Write the manifest to `{configDir}/manifest.json` in addition to stdout |
20
- | `--platform <github\|ado>` | Override platform detection (default: auto-detected) |
20
+ | `--platform <github|ado>` | Select the platform; defaults to `ado` |
21
+ | `--children <dirs>` | Comma- or space-separated list of top-level directories to scan for packages (e.g. `packages,apps`). Overrides auto-detection from `pnpm-workspace.yaml` or `package.json` workspaces. |
22
+ | `--exclude <dirs>` | Comma- or space-separated list of top-level directories to drop from whichever list of children is in effect — auto-detected or from `--children` (e.g. auto-detect everything in `pnpm-workspace.yaml` but skip `tools`). |
21
23
  | `--debug` | Print the number of discovered packages |
22
24
 
23
- ## Platform Detection
25
+ ## Platform
24
26
 
25
- The platform is auto-detected by looking for the following files:
26
-
27
- - `.github/templates/templated.json` or `.github/manifest.json` → `github` (uses `.github/`)
28
- - Neither found → `ado` (uses `.cicd/`)
29
-
30
- Pass `--platform github` or `--platform ado` to override.
27
+ The platform defaults to `ado`, which uses `.cicd/`. Pass `--platform github` to use `.github/`.
31
28
 
32
29
  ## Output
33
30
 
@@ -35,15 +32,19 @@ The manifest is a JSON object with six top-level keys:
35
32
 
36
33
  ```json
37
34
  {
35
+ "version": 2,
38
36
  "versions": { "<package-name>": "<version>", ... },
39
37
  "dependencies": { "<package-name>": ["<local-dep>", ...], ... },
40
38
  "dependents": { "<package-name>": ["<consumer>", ...], ... },
41
39
  "pipelines": { "<template-file>": [ <stage>, ... ], ... },
42
- "templated": { "<template-file>": { "<placeholder>": { "<package-name>": "<job-template>", ... } } },
43
40
  "cicd": { "<package-name>": { "output": "...", "language": "...", "tier": "...", ... }, ... }
44
41
  }
45
42
  ```
46
43
 
44
+ ### `version`
45
+
46
+ The manifest's schema version. Always `2` in the current version of this tool.
47
+
47
48
  ### `versions`
48
49
 
49
50
  Maps every local package name to its current version from `package.json`.
@@ -57,21 +58,9 @@ Maps every local package name to its current version from `package.json`.
57
58
 
58
59
  Populated when `{configDir}/templates/templated.json` exists. Each key is a pipeline template filename; the value is an ordered list of resolved stages with their associated package lists.
59
60
 
60
- ### `templated`
61
-
62
- Stores the template routing that used to live in `templated.json`. The first key is the output file name, the second key is the placeholder name, and the innermost map assigns each package name to the job template file or files it should use.
61
+ ### Template routing
63
62
 
64
- Example:
65
-
66
- ```json
67
- {
68
- "build.yml": {
69
- "PACKAGES": {
70
- "enablement-build-deploy-version": "build_node_component.yml"
71
- }
72
- }
73
- }
74
- ```
63
+ Template routing (which job template each package uses) no longer lives in `manifest.json` — it's read from and written to `{configDir}/templates/templated.json` directly, in the shape [packages/template-generate](../template-generate) expects: `{ "version": 2, "templated": { "<template-file>": { "<placeholder>": { "<package-name>": "<job-template>", ... } } }, "cicd": { "<package-name>": { "folderName": "...", "packageFolder": "...", "metadata": {...} } } }`. Every `--save` run migrates any leftover routing it finds in `manifest.json` into this file, additively (existing assignments in `templated.json` win over stale ones from `manifest.json`).
75
64
 
76
65
  ### `cicd`
77
66
 
@@ -87,6 +76,7 @@ CI/CD metadata for each package. Sourced from the `.cicd` field in `package.json
87
76
  | `pyproject.toml` present | `pypi` | `Python` | `api` |
88
77
  | `Dockerfile` in image/container path | `docker` | `Dockerfile` | `infra` |
89
78
 
79
+ A `hash` field left over from an older manifest is moved to `{configDir}/hash.json` on the next `--save` and removed from `cicd`.
90
80
 
91
81
  ## Example
92
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "enablement-build-manifest",
3
- "version": "1.0.4",
3
+ "version": "1.4.0",
4
4
  "description": "This builds a dependency tree.",
5
5
  "type": "module",
6
6
  "module": "./index.mjs",
@@ -9,6 +9,7 @@
9
9
  "author": "Contributors",
10
10
  "dependencies": {
11
11
  "globby": "^16.2.0",
12
+ "minimatch": "~10.1.1",
12
13
  "yaml": "^2.8.1"
13
14
  },
14
15
  "scripts": {
@@ -0,0 +1,26 @@
1
+ // Order `names` so that, as much as possible, each name comes after every
2
+ // local dependency it has (per `dependencies`, a name -> [depName, ...] map —
3
+ // the same shape as manifest.json's "dependencies" property). Dependencies
4
+ // that aren't in `names` are ignored (they're outside whatever list is being
5
+ // ordered). Cycles can't be fully resolved; when one is hit, the first member
6
+ // reached is emitted without waiting on the rest, so this never hangs or
7
+ // throws — it just orders what it safely can.
8
+ export function topologicalSort(names, dependencies = {}) {
9
+ const nameSet = new Set(names);
10
+ const visited = new Set();
11
+ const result = [];
12
+
13
+ function visit(name, stack) {
14
+ if (visited.has(name) || stack.has(name)) return;
15
+ stack.add(name);
16
+ for (const dep of dependencies[name] || []) {
17
+ if (nameSet.has(dep)) visit(dep, stack);
18
+ }
19
+ stack.delete(name);
20
+ visited.add(name);
21
+ result.push(name);
22
+ }
23
+
24
+ for (const name of names) visit(name, new Set());
25
+ return result;
26
+ }
package/src/detect.mjs ADDED
@@ -0,0 +1,48 @@
1
+ import { globbySync } from 'globby';
2
+ import path from 'path';
3
+
4
+ // Classify a package's cicd output/language/tier/packageType from its package.json
5
+ // contents and directory. Returns null when nothing matches.
6
+ //
7
+ // pkgPath is the absolute path to the package's package.json (matching the shape
8
+ // generateManifest already has on hand); the package directory is derived from it.
9
+ export function detectCicdType(pkgName, pkg, pkgPath) {
10
+ if (pkg['main'] || pkg['module']) {
11
+ const result = { output: 'npm', language: 'JavaScript', tier: 'component' };
12
+ if (pkg['bin']) {
13
+ result.packageType = 'cli';
14
+ result.tier = 'tooling';
15
+ }
16
+ return result;
17
+ }
18
+
19
+ if ((pkg['devDependencies'] && pkg['devDependencies']['moleculer']) || (pkg['dependencies'] && pkg['dependencies']['moleculer'])) {
20
+ return { output: 'npm', language: 'node', packageType: 'moleculer', tier: 'api' };
21
+ }
22
+
23
+ if ((pkg['devDependencies'] && pkg['devDependencies']['react']) || (pkg['dependencies'] && pkg['dependencies']['react'])) {
24
+ return { output: 'npm', language: 'JavaScript', packageType: 'ux', tier: 'ux' };
25
+ }
26
+
27
+ // not node
28
+ const pkgDir = path.dirname(pkgPath);
29
+ const csproj = globbySync('*.csproj', { cwd: pkgDir, absolute: true });
30
+ const toml = globbySync('pyproject.toml', { cwd: pkgDir, absolute: true });
31
+ const docker = globbySync('Dockerfile', { cwd: pkgDir, absolute: true });
32
+
33
+ if (csproj.length > 0) {
34
+ return { output: 'nuget', language: 'C#', packageType: 'dotnet', tier: 'api' };
35
+ }
36
+
37
+ if (toml.length > 0) {
38
+ const result = { output: 'pypi', language: 'Python', tier: 'api' };
39
+ if (pkgName.toLocaleLowerCase().endsWith('api')) result.packageType = 'pypi';
40
+ return result;
41
+ }
42
+
43
+ if (docker.length > 0 && (pkgPath.indexOf('image') >= 0 || pkgPath.indexOf('container') >= 0)) {
44
+ return { output: 'docker', language: 'Dockerfile', tier: 'infra' };
45
+ }
46
+
47
+ return null;
48
+ }
@@ -0,0 +1,502 @@
1
+ import path from 'path';
2
+ import crypto from 'crypto';
3
+ import fs from 'fs';
4
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
5
+ import { minimatch } from 'minimatch';
6
+
7
+ // ─── hash.json file handling ─────────────────────────────────────────────────
8
+ //
9
+ // Hashes are always stored in a hash.json file, keyed by short package folder
10
+ // name. When the requested --hashFile path points at a manifest.json (legacy
11
+ // "manifest mode"), storage is redirected to a sibling hash.json instead of
12
+ // writing hash data into the manifest.
13
+ //
14
+ // hash.json schema versions. 1 = legacy (flat map of name -> {hash, version,
15
+ // fullName, packageFolder}, no wrapper). 2 = current (name -> hash string,
16
+ // nested under a "hash" property, alongside a top-level "version"). Every
17
+ // write stamps HASH_FILE_VERSION; version 1 is only ever inferred on read,
18
+ // for files that predate the wrapper.
19
+ const HASH_FILE_VERSION = 2;
20
+
21
+ // Resolve the physical file used to store hash state, given the requested
22
+ // hashFile path (relative to prefixPath).
23
+ export function resolveHashFilePath(prefixPath, hashFile) {
24
+ const requestedHashFile = path.join(prefixPath, hashFile);
25
+ const usesManifestHashFile = hashFile.replace(/\\/g, '/').endsWith('/manifest.json');
26
+ const changeConfig = usesManifestHashFile
27
+ ? path.join(path.dirname(requestedHashFile), 'hash.json')
28
+ : requestedHashFile;
29
+ return { changeConfig, usesManifestHashFile };
30
+ }
31
+
32
+ // Create an empty hash file if it doesn't exist yet, so subsequent reads
33
+ // always find valid JSON. Under options.try, only logs what would happen.
34
+ export function ensureHashFileExists(changeConfig, options = {}) {
35
+ if (existsSync(changeConfig)) return;
36
+ if (options.try) {
37
+ console.log('\x1b[36m%s\x1b[0m', `[try] Hash file ${changeConfig} not found — treating previous state as empty`);
38
+ } else {
39
+ writeFileSync(changeConfig, "{}", "utf8");
40
+ }
41
+ }
42
+
43
+ // Load previously-recorded hashes, keyed by short package folder name, as
44
+ // { name: { hash } }. Understands both the current format (hash values under
45
+ // a "hash" property) and the legacy flat format, so callers never need to
46
+ // know which one is on disk.
47
+ export function loadHashFile(changeConfig) {
48
+ const data = existsSync(changeConfig) ? readFileSync(changeConfig, "utf8") : "{}";
49
+ const raw = JSON.parse(data || "{}");
50
+
51
+ if (raw.hash && typeof raw.hash === 'object') {
52
+ return Object.fromEntries(Object.entries(raw.hash).map(([name, hash]) => [name, { hash }]));
53
+ }
54
+
55
+ // Legacy format: { name: { hash, version?, fullName?, packageFolder? }, ... }
56
+ const { version, ...legacyEntries } = raw;
57
+ return Object.fromEntries(
58
+ Object.entries(legacyEntries).map(([name, entry]) => [name, { hash: entry && entry.hash }])
59
+ );
60
+ }
61
+
62
+ // Persist current hashes. Every write stamps the current schema version and
63
+ // nests hash values (name -> hash string) under a "hash" property. In
64
+ // manifest mode, only the touched packages' hashes are updated — every other
65
+ // package already on disk is left alone. Otherwise, the file is fully
66
+ // replaced with just the sorted current hash map.
67
+ export function writeHashFile(changeConfig, current, { usesManifestHashFile }) {
68
+ let hashes;
69
+ if (usesManifestHashFile) {
70
+ const existing = loadHashFile(changeConfig);
71
+ Object.keys(current).forEach(name => {
72
+ existing[name] = { hash: current[name].hash };
73
+ });
74
+ hashes = Object.fromEntries(Object.entries(existing).map(([name, entry]) => [name, entry.hash]));
75
+ } else {
76
+ hashes = Object.fromEntries(Object.keys(current).sort().map(name => [name, current[name].hash]));
77
+ }
78
+ writeFileSync(changeConfig, JSON.stringify({ version: HASH_FILE_VERSION, hash: hashes }, null, 2), "utf8");
79
+ }
80
+
81
+ // ─── Recursive folder hashing ────────────────────────────────────────────────
82
+ //
83
+ // Hashes a file/folder tree, honoring include/exclude rules per file, folder,
84
+ // and symbolic link. Adapted from the "folder-hash" package.
85
+
86
+ const defaultOptions = {
87
+ algo: 'sha1', // see crypto.getHashes() for options
88
+ encoding: 'base64', // 'base64', 'base64url', 'hex' or 'binary'
89
+ files: {
90
+ exclude: [],
91
+ include: [],
92
+ matchBasename: true,
93
+ matchPath: false,
94
+ ignoreBasename: false,
95
+ ignoreRootName: false,
96
+ },
97
+ folders: {
98
+ exclude: [],
99
+ include: [],
100
+ matchBasename: true,
101
+ matchPath: false,
102
+ ignoreBasename: false,
103
+ ignoreRootName: false,
104
+ },
105
+ symbolicLinks: {
106
+ include: true,
107
+ ignoreBasename: false,
108
+ ignoreTargetPath: true,
109
+ ignoreTargetContent: false,
110
+ ignoreTargetContentAfterError: false,
111
+ },
112
+ };
113
+
114
+ const debug = function(txt) {
115
+ return params => {
116
+ console.log(txt, params);
117
+ return params;
118
+ }
119
+ }
120
+
121
+ // Use the environment variable DEBUG to log output, e.g. `set DEBUG=fhash:*`
122
+ const log = {
123
+ match: function() {},
124
+ params: function(params) {return params},
125
+ err: debug('fhash:err'),
126
+ symlink: debug('fhash:symlink'),
127
+ queue: function() {}
128
+ };
129
+
130
+ function prep(fs) {
131
+ let queue = [];
132
+ let queueTimer = undefined;
133
+
134
+ function hashElement(name, dir, options, callback) {
135
+ callback = arguments[arguments.length - 1];
136
+
137
+ return parseParameters(arguments)
138
+ .then(({ basename, dir, options }) => {
139
+ // this is only used for the root level
140
+ options.skipMatching = true;
141
+ return fs.promises
142
+ .lstat(path.join(dir, basename))
143
+ .then(stats => {
144
+ stats.name = basename;
145
+ return stats;
146
+ })
147
+ .then(stats => hashElementPromise(stats, dir, options, true));
148
+ })
149
+ .then(result => {
150
+ if (isFunction(callback)) {
151
+ return callback(undefined, result);
152
+ } else {
153
+ return result;
154
+ }
155
+ })
156
+ .catch(reason => {
157
+ log.err('Fatal error:', reason);
158
+ if (isFunction(callback)) {
159
+ return callback(reason);
160
+ } else {
161
+ throw reason;
162
+ }
163
+ });
164
+ }
165
+
166
+ /**
167
+ * @param {fs.Stats} stats folder element, can also be of type fs.Dirent
168
+ * @param {string} dirname
169
+ * @param {Options} options
170
+ * @param {boolean} isRootElement
171
+ */
172
+ function hashElementPromise(stats, dirname, options, isRootElement = false) {
173
+ const name = stats.name;
174
+ let promise = undefined;
175
+ if (stats.isDirectory()) {
176
+ promise = hashFolderPromise(name, dirname, options, isRootElement);
177
+ } else if (stats.isFile()) {
178
+ promise = hashFilePromise(name, dirname, options, isRootElement);
179
+ } else if (stats.isSymbolicLink()) {
180
+ promise = hashSymLinkPromise(name, dirname, options, isRootElement);
181
+ } else {
182
+ log.err('hashElementPromise cannot handle ', stats);
183
+ return Promise.resolve({ name, hash: 'Error: unknown element type' });
184
+ }
185
+
186
+ return promise.catch(err => {
187
+ if (err.code && (err.code === 'EMFILE' || err.code === 'ENFILE')) {
188
+ log.queue(`queued ${dirname}/${name} because of ${err.code}`);
189
+
190
+ const promise = new Promise((resolve, reject) => {
191
+ queue.push(() => {
192
+ log.queue(`Will processs queued ${dirname}/${name}`);
193
+ return hashElementPromise(stats, dirname, options, isRootElement)
194
+ .then(ok => resolve(ok))
195
+ .catch(err => reject(err));
196
+ });
197
+ });
198
+
199
+ if (queueTimer === undefined) {
200
+ queueTimer = setTimeout(processQueue, 0);
201
+ }
202
+ return promise;
203
+ }
204
+
205
+ throw err;
206
+ });
207
+ }
208
+
209
+ function processQueue() {
210
+ queueTimer = undefined;
211
+ const runnables = queue;
212
+ queue = [];
213
+ runnables.forEach(run => run());
214
+ }
215
+
216
+ async function hashFolderPromise(name, dir, options, isRootElement = false) {
217
+ const folderPath = path.join(dir, name);
218
+ let ignoreBasenameOnce = options.ignoreBasenameOnce;
219
+ delete options.ignoreBasenameOnce;
220
+
221
+ if (options.skipMatching) {
222
+ // this is currently only used for the root folder
223
+ log.match(`skipped '${folderPath}'`);
224
+ delete options.skipMatching;
225
+ } else if (ignore(name, folderPath, options.folders)) {
226
+ return undefined;
227
+ }
228
+
229
+ const files = await fs.promises.readdir(folderPath, { withFileTypes: true });
230
+ const children = await Promise.all(
231
+ files
232
+ .sort((a, b) => a.name.localeCompare(b.name))
233
+ .map(child => hashElementPromise(child, folderPath, options)),
234
+ );
235
+
236
+ if (ignoreBasenameOnce) options.ignoreBasenameOnce = true;
237
+ const hash = new HashedFolder(name, children.filter(notUndefined), options, isRootElement);
238
+ return hash;
239
+ }
240
+
241
+ function hashFilePromise(name, dir, options, isRootElement = false) {
242
+ const filePath = path.join(dir, name);
243
+
244
+ if (options.skipMatching) {
245
+ // this is currently only used for the root folder
246
+ log.match(`skipped '${filePath}'`);
247
+ delete options.skipMatching;
248
+ } else if (ignore(name, filePath, options.files)) {
249
+ return Promise.resolve(undefined);
250
+ }
251
+
252
+ return new Promise((resolve, reject) => {
253
+ try {
254
+ const hash = crypto.createHash(options.algo);
255
+ if (
256
+ options.files.ignoreBasename ||
257
+ options.ignoreBasenameOnce ||
258
+ (isRootElement && options.files.ignoreRootName)
259
+ ) {
260
+ delete options.ignoreBasenameOnce;
261
+ log.match(`omitted name of ${filePath} from hash`);
262
+ } else {
263
+ hash.update(name);
264
+ }
265
+
266
+ const f = fs.createReadStream(filePath);
267
+ f.on('error', err => {
268
+ reject(err);
269
+ });
270
+ f.pipe(hash, { end: false });
271
+
272
+ f.on('end', () => {
273
+ const hashedFile = new HashedFile(name, hash, options.encoding);
274
+ return resolve(hashedFile);
275
+ });
276
+ } catch (ex) {
277
+ return reject(ex);
278
+ }
279
+ });
280
+ }
281
+
282
+ async function hashSymLinkPromise(name, dir, options, isRootElement = false) {
283
+ const target = await fs.promises.readlink(path.join(dir, name));
284
+ log.symlink(`handling symbolic link ${name} -> ${target}`);
285
+ if (options.symbolicLinks.include) {
286
+ if (options.symbolicLinks.ignoreTargetContent) {
287
+ return symLinkIgnoreTargetContent(name, target, options, isRootElement);
288
+ } else {
289
+ return symLinkResolve(name, dir, target, options, isRootElement);
290
+ }
291
+ } else {
292
+ log.symlink('skipping symbolic link');
293
+ return Promise.resolve(undefined);
294
+ }
295
+ }
296
+
297
+ function symLinkIgnoreTargetContent(name, target, options, isRootElement) {
298
+ delete options.skipMatching; // only used for the root level
299
+ log.symlink('ignoring symbolic link target content');
300
+ const hash = crypto.createHash(options.algo);
301
+ if (!options.symbolicLinks.ignoreBasename && !(isRootElement && options.files.ignoreRootName)) {
302
+ log.symlink('hash basename');
303
+ hash.update(name);
304
+ }
305
+ if (!options.symbolicLinks.ignoreTargetPath) {
306
+ log.symlink('hash targetpath');
307
+ hash.update(target);
308
+ }
309
+ return Promise.resolve(new HashedFile(name, hash, options.encoding));
310
+ }
311
+
312
+ async function symLinkResolve(name, dir, target, options, isRootElement) {
313
+ delete options.skipMatching; // only used for the root level
314
+ if (options.symbolicLinks.ignoreBasename) {
315
+ options.ignoreBasenameOnce = true;
316
+ }
317
+
318
+ try {
319
+ const stats = await fs.promises.stat(path.join(dir, name));
320
+ stats.name = name;
321
+ const temp = await hashElementPromise(stats, dir, options, isRootElement);
322
+
323
+ if (!options.symbolicLinks.ignoreTargetPath) {
324
+ const hash = crypto.createHash(options.algo);
325
+ hash.update(temp.hash);
326
+ log.symlink('hash targetpath');
327
+ hash.update(target);
328
+ temp.hash = hash.digest(options.encoding);
329
+ }
330
+ return temp;
331
+ } catch (err) {
332
+ if (options.symbolicLinks.ignoreTargetContentAfterError) {
333
+ log.symlink(`Ignoring error "${err.code}" when hashing symbolic link ${name}`, err);
334
+ const hash = crypto.createHash(options.algo);
335
+ if (
336
+ !options.symbolicLinks.ignoreBasename &&
337
+ !(isRootElement && options.files.ignoreRootName)
338
+ ) {
339
+ hash.update(name);
340
+ }
341
+ if (!options.symbolicLinks.ignoreTargetPath) {
342
+ hash.update(target);
343
+ }
344
+ return new HashedFile(name, hash, options.encoding);
345
+ } else {
346
+ log.symlink(`Error "${err.code}": When hashing symbolic link ${name}`, err);
347
+ throw err;
348
+ }
349
+ }
350
+ }
351
+
352
+ function ignore(name, path, rules) {
353
+ if (rules.exclude) {
354
+ if (rules.matchBasename && rules.exclude(name)) {
355
+ log.match(`exclude basename '${name}'`);
356
+ return true;
357
+ } else if (rules.matchPath && rules.exclude(path)) {
358
+ log.match(`exclude path '${path}'`);
359
+ return true;
360
+ }
361
+ }
362
+ if (rules.include) {
363
+ if (rules.matchBasename && rules.include(name)) {
364
+ log.match(`include basename '${name}'`);
365
+ return false;
366
+ } else if (rules.matchPath && rules.include(path)) {
367
+ log.match(`include path '${path}'`);
368
+ return false;
369
+ } else {
370
+ log.match(`include rule failed for path '${path}'`);
371
+ return true;
372
+ }
373
+ }
374
+
375
+ log.match(`Will not ignore unmatched '${path}'`);
376
+ return false;
377
+ }
378
+
379
+ return hashElement;
380
+ }
381
+
382
+ function parseParameters(args) {
383
+ let basename = args[0],
384
+ dir = args[1],
385
+ options_ = args[2];
386
+
387
+ if (!isString(basename)) {
388
+ return Promise.reject(new TypeError('First argument must be a string'));
389
+ }
390
+
391
+ if (!isString(dir)) {
392
+ dir = path.dirname(basename);
393
+ basename = path.basename(basename);
394
+ options_ = args[1];
395
+ }
396
+
397
+ // parse options (fallback default options)
398
+ if (!isObject(options_)) options_ = {};
399
+ const options = {
400
+ algo: options_.algo || defaultOptions.algo,
401
+ encoding: options_.encoding || defaultOptions.encoding,
402
+ files: Object.assign({}, defaultOptions.files, options_.files),
403
+ folders: Object.assign({}, defaultOptions.folders, options_.folders),
404
+ match: Object.assign({}, defaultOptions.match, options_.match),
405
+ symbolicLinks: Object.assign({}, defaultOptions.symbolicLinks, options_.symbolicLinks),
406
+ };
407
+
408
+ // transform match globs to Regex
409
+ options.files.exclude = reduceGlobPatterns(options.files.exclude);
410
+ options.files.include = reduceGlobPatterns(options.files.include);
411
+ options.folders.exclude = reduceGlobPatterns(options.folders.exclude);
412
+ options.folders.include = reduceGlobPatterns(options.folders.include);
413
+
414
+ return Promise.resolve(log.params({ basename, dir, options }));
415
+ }
416
+
417
+ const HashedFolder = function HashedFolder(name, children, options, isRootElement = false) {
418
+ this.name = name;
419
+ this.children = children;
420
+
421
+ const hash = crypto.createHash(options.algo);
422
+ if (
423
+ options.folders.ignoreBasename ||
424
+ options.ignoreBasenameOnce ||
425
+ (isRootElement && options.folders.ignoreRootName)
426
+ ) {
427
+ delete options.ignoreBasenameOnce;
428
+ log.match(`omitted name of folder ${name} from hash`);
429
+ } else {
430
+ hash.update(name);
431
+ }
432
+ children.forEach(child => {
433
+ if (child.hash) {
434
+ hash.update(child.hash);
435
+ }
436
+ });
437
+
438
+ this.hash = hash.digest(options.encoding);
439
+ };
440
+
441
+ HashedFolder.prototype.toString = function (padding = '') {
442
+ const first = `${padding}{ name: '${this.name}', hash: '${this.hash}',\n`;
443
+ padding += ' ';
444
+
445
+ return `${first}${padding}children: ${this.childrenToString(padding)}}`;
446
+ };
447
+
448
+ HashedFolder.prototype.childrenToString = function (padding = '') {
449
+ if (this.children.length === 0) {
450
+ return '[]';
451
+ } else {
452
+ const nextPadding = padding + ' ';
453
+ const children = this.children.map(child => child.toString(nextPadding)).join('\n');
454
+ return `[\n${children}\n${padding}]`;
455
+ }
456
+ };
457
+
458
+ const HashedFile = function HashedFile(name, hash, encoding) {
459
+ this.name = name;
460
+ this.hash = hash.digest(encoding);
461
+ };
462
+
463
+ HashedFile.prototype.toString = function (padding = '') {
464
+ return padding + "{ name: '" + this.name + "', hash: '" + this.hash + "' }";
465
+ };
466
+
467
+ function isFunction(any) {
468
+ return typeof any === 'function';
469
+ }
470
+
471
+ function isString(str) {
472
+ return typeof str === 'string' || str instanceof String;
473
+ }
474
+
475
+ function isObject(obj) {
476
+ return obj !== null && typeof obj === 'object';
477
+ }
478
+
479
+ function notUndefined(obj) {
480
+ return typeof obj !== 'undefined';
481
+ }
482
+
483
+ function reduceGlobPatterns(globs) {
484
+ if (isFunction(globs)) {
485
+ return globs;
486
+ } else if (!globs || !Array.isArray(globs) || globs.length === 0) {
487
+ return undefined;
488
+ } else {
489
+ // combine globs into one single RegEx
490
+ const regex = new RegExp(
491
+ globs
492
+ .reduce((acc, exclude) => {
493
+ return acc + '|' + minimatch.makeRe(exclude).source;
494
+ }, '')
495
+ .substr(1),
496
+ );
497
+ return param => regex.test(param);
498
+ }
499
+ }
500
+
501
+ export const defaults = defaultOptions;
502
+ export const hashElement = prep(fs);