cliguard 0.3.1 → 0.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 +11 -1
- package/dist/adapters/cac.adapter.d.ts +2 -0
- package/dist/adapters/cac.adapter.js +24 -2
- package/dist/adapters/commander.adapter.d.ts +2 -0
- package/dist/adapters/commander.adapter.js +33 -8
- package/dist/adapters/construction-capture.d.ts +35 -0
- package/dist/adapters/construction-capture.js +80 -0
- package/dist/bin.js +62 -20
- package/dist/core/diff.engine.d.ts +2 -0
- package/dist/core/diff.engine.js +10 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ The first line fails your CI. The second one doesn't - `--dry-run` is new and op
|
|
|
23
23
|
npm install --save-dev cliguard
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Your CLI's entry file
|
|
26
|
+
Your CLI's entry file should **export** its Commander `Command` instance instead of calling `.parse()` itself - the cleanest way to adopt cliguard, since it never risks running any of your CLI's real logic:
|
|
27
27
|
|
|
28
28
|
```js
|
|
29
29
|
// bin/cli.js
|
|
@@ -56,6 +56,16 @@ npx cliguard init ./bin/cli.js --adapter cac
|
|
|
56
56
|
|
|
57
57
|
`cac` itself is an optional dependency of cliguard - only installed if you actually use `--adapter cac`.
|
|
58
58
|
|
|
59
|
+
### Entry files that build the CLI lazily
|
|
60
|
+
|
|
61
|
+
Not every real CLI exports its instance - plenty build it inside a function that only runs when something actually calls it, or just never had a reason to export it. Pointing cliguard straight at a file like that would fail with "no instance found" under the rule above alone.
|
|
62
|
+
|
|
63
|
+
So when the direct export lookup finds nothing, cliguard tries one more thing automatically, no flag needed: it patches the exact copy of `commander`/`cac` your entry file will itself `require()`, so any `new Command()` (or CAC's `cac()`) call anywhere in your file's own top-level code is captured - even though nothing was ever exported. This covers most real CLIs, since even ones that never bother exporting still build (and often `.parse()`) at the top of their own file as a matter of course.
|
|
64
|
+
|
|
65
|
+
It can't reach an instance built strictly *inside* a function that's only invoked later, never automatically at load time (a `main()` some other file calls, not the file cliguard is pointed at) - there's no safe, generic way for cliguard to know which function to call or with what arguments. For that shape, write a small wrapper file that reaches into the target's own internals to get (or construct) the instance, and point cliguard at the wrapper instead of the original entry file. The exact shape of that wrapper is inherently project-specific - it's standing in for whatever that project's own entry point would otherwise do - but the command stays the same either way: `cliguard init ./your-wrapper.mjs`.
|
|
66
|
+
|
|
67
|
+
Either way, if the target's top-level code has its own real side effects when loaded - a `.parse()` call that matches a real command and runs it, a network request, spawning a process - running cliguard against it (directly or through a wrapper) triggers those too, exactly as `node ./bin/cli.js` would. cliguard neutralizes one specific danger this creates (a target calling `process.exit()` can't kill cliguard's own process or override its exit code), but doesn't sandbox anything else - see [SECURITY.md](./SECURITY.md).
|
|
68
|
+
|
|
59
69
|
Then:
|
|
60
70
|
|
|
61
71
|
```sh
|
|
@@ -19,6 +19,8 @@ export declare class CacAdapter implements CliAdapter {
|
|
|
19
19
|
readonly id = "cac";
|
|
20
20
|
extract(entryPath: string): Promise<Contract>;
|
|
21
21
|
private loadCac;
|
|
22
|
+
/** Among every CAC instance captured during construction, the one that looks most like the real, fully-built root CLI. */
|
|
23
|
+
private pickBestCandidate;
|
|
22
24
|
/** Handles `export default`, `module.exports = cli`, and named exports. */
|
|
23
25
|
private findCac;
|
|
24
26
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CacAdapter = void 0;
|
|
4
|
+
const construction_capture_1 = require("./construction-capture");
|
|
4
5
|
const load_module_1 = require("./load-module");
|
|
5
6
|
/**
|
|
6
7
|
* Extracts a Contract from a target file that exports a `cac()` `CAC`
|
|
@@ -31,18 +32,39 @@ class CacAdapter {
|
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
async loadCac(entryPath) {
|
|
35
|
+
// See CommanderAdapter.loadCommand's identical block: patched before
|
|
36
|
+
// the target loads, so a `new CAC()` or `cac()` call anywhere in its
|
|
37
|
+
// own top-level code gets captured even if the target never exports
|
|
38
|
+
// the result.
|
|
39
|
+
const captured = (0, construction_capture_1.captureConstructions)("cac", entryPath, "CAC", ["cac"]);
|
|
34
40
|
const { viaImport, viaRequire } = await (0, load_module_1.loadModule)(entryPath);
|
|
35
|
-
const cli = this.findCac(viaImport.moduleExports) ??
|
|
41
|
+
const cli = this.findCac(viaImport.moduleExports) ??
|
|
42
|
+
this.findCac(viaRequire.moduleExports) ??
|
|
43
|
+
this.pickBestCandidate(captured);
|
|
36
44
|
if (cli)
|
|
37
45
|
return cli;
|
|
38
46
|
// See CommanderAdapter's identical block for why both real errors -
|
|
39
47
|
// not a swallowed, generic guess - matter here.
|
|
40
48
|
throw new Error(`cliguard: no CAC instance found in "${entryPath}". ` +
|
|
41
49
|
"Export it as `export default cli`, `module.exports = cli`, " +
|
|
42
|
-
"or a named export (e.g. `export const cli = cac()`)
|
|
50
|
+
"or a named export (e.g. `export const cli = cac()`). If the file builds its " +
|
|
51
|
+
"CAC instance inside a function that only runs when something calls it (never " +
|
|
52
|
+
"at the top level), point cliguard at a small wrapper file that calls that " +
|
|
53
|
+
"function and exports the result instead - see the README's \"Entry files that " +
|
|
54
|
+
'build the CLI lazily" section.\n' +
|
|
43
55
|
` import() failed: ${viaImport.error ?? "module loaded, but exported no CAC instance"}\n` +
|
|
44
56
|
` require() failed: ${viaRequire.error ?? "module loaded, but exported no CAC instance"}`);
|
|
45
57
|
}
|
|
58
|
+
/** Among every CAC instance captured during construction, the one that looks most like the real, fully-built root CLI. */
|
|
59
|
+
pickBestCandidate(candidates) {
|
|
60
|
+
const valid = candidates.filter((candidate) => this.looksLikeCac(candidate));
|
|
61
|
+
if (valid.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
return valid.reduce((best, candidate) => candidate.commands.length + candidate.globalCommand.options.length >
|
|
64
|
+
best.commands.length + best.globalCommand.options.length
|
|
65
|
+
? candidate
|
|
66
|
+
: best);
|
|
67
|
+
}
|
|
46
68
|
/** Handles `export default`, `module.exports = cli`, and named exports. */
|
|
47
69
|
findCac(moduleExports) {
|
|
48
70
|
if (this.looksLikeCac(moduleExports)) {
|
|
@@ -11,6 +11,8 @@ export declare class CommanderAdapter implements CliAdapter {
|
|
|
11
11
|
readonly id = "commander";
|
|
12
12
|
extract(entryPath: string): Promise<Contract>;
|
|
13
13
|
private loadCommand;
|
|
14
|
+
/** Among every Command captured during construction, the one that looks most like the real, fully-built root program - flags on this file's own top-level code sometimes constructing more than one incidentally. */
|
|
15
|
+
private pickBestCandidate;
|
|
14
16
|
/** Handles `export default`, `module.exports = program`, and named exports. */
|
|
15
17
|
private findCommand;
|
|
16
18
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CommanderAdapter = void 0;
|
|
4
|
+
const construction_capture_1 = require("./construction-capture");
|
|
4
5
|
const load_module_1 = require("./load-module");
|
|
5
6
|
/**
|
|
6
7
|
* Extracts a Contract from a target file that exports a Commander.js
|
|
@@ -23,25 +24,49 @@ class CommanderAdapter {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
async loadCommand(entryPath) {
|
|
27
|
+
// Patched *before* the target loads, so a `new Command()` or
|
|
28
|
+
// `createCommand()` call anywhere in its own top-level code gets
|
|
29
|
+
// captured as a side effect of loadModule() below - even if the
|
|
30
|
+
// target never exports the result anywhere. See
|
|
31
|
+
// construction-capture.ts for why this is safe and what it can't
|
|
32
|
+
// reach.
|
|
33
|
+
const captured = (0, construction_capture_1.captureConstructions)("commander", entryPath, "Command", ["createCommand"]);
|
|
26
34
|
const { viaImport, viaRequire } = await (0, load_module_1.loadModule)(entryPath);
|
|
27
|
-
const command = this.findCommand(viaImport.moduleExports) ??
|
|
35
|
+
const command = this.findCommand(viaImport.moduleExports) ??
|
|
36
|
+
this.findCommand(viaRequire.moduleExports) ??
|
|
37
|
+
this.pickBestCandidate(captured);
|
|
28
38
|
if (command)
|
|
29
39
|
return command;
|
|
30
|
-
// Neither attempt's exports contained a Command
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
40
|
+
// Neither attempt's exports contained a Command, and nothing was
|
|
41
|
+
// captured during construction either. One load attempt failing on
|
|
42
|
+
// its own is normal and expected (an ESM-only file can't require(),
|
|
43
|
+
// a CJS one may reject a bare import() on an older Node) - the
|
|
44
|
+
// interesting case is when the file simply never loaded at all (a
|
|
45
|
+
// syntax error, a missing dependency inside it), which the generic
|
|
46
|
+
// "no Command instance found" message below would otherwise
|
|
36
47
|
// misrepresent as "loaded fine, wrong export shape." Surface both
|
|
37
48
|
// real reasons so the actual cause - a broken file vs. a genuinely
|
|
38
49
|
// missing export - is never a guess.
|
|
39
50
|
throw new Error(`cliguard: no Commander.js Command instance found in "${entryPath}". ` +
|
|
40
51
|
"Export it as `export default program`, `module.exports = program`, " +
|
|
41
|
-
"or a named export (e.g. `export const program = new Command()`)
|
|
52
|
+
"or a named export (e.g. `export const program = new Command()`). If the file " +
|
|
53
|
+
"builds its Command inside a function that only runs when something calls it " +
|
|
54
|
+
"(never at the top level), point cliguard at a small wrapper file that calls " +
|
|
55
|
+
"that function and exports the result instead - see the README's " +
|
|
56
|
+
'"Entry files that build the CLI lazily" section.\n' +
|
|
42
57
|
` import() failed: ${viaImport.error ?? "module loaded, but exported no Command instance"}\n` +
|
|
43
58
|
` require() failed: ${viaRequire.error ?? "module loaded, but exported no Command instance"}`);
|
|
44
59
|
}
|
|
60
|
+
/** Among every Command captured during construction, the one that looks most like the real, fully-built root program - flags on this file's own top-level code sometimes constructing more than one incidentally. */
|
|
61
|
+
pickBestCandidate(candidates) {
|
|
62
|
+
const valid = candidates.filter((candidate) => this.looksLikeCommand(candidate));
|
|
63
|
+
if (valid.length === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
return valid.reduce((best, candidate) => candidate.options.length + candidate.commands.length >
|
|
66
|
+
best.options.length + best.commands.length
|
|
67
|
+
? candidate
|
|
68
|
+
: best);
|
|
69
|
+
}
|
|
45
70
|
/** Handles `export default`, `module.exports = program`, and named exports. */
|
|
46
71
|
findCommand(moduleExports) {
|
|
47
72
|
if (this.looksLikeCommand(moduleExports)) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captures whatever instance a target file's own top-level code
|
|
3
|
+
* constructs from `packageName` - even when the target never exports
|
|
4
|
+
* that instance anywhere `module.exports` reaches. Many real CLIs build
|
|
5
|
+
* their Commander/CAC instance inside a function that only runs when
|
|
6
|
+
* something actually calls it (or just never bother exporting it, since
|
|
7
|
+
* they have no reason to) - `findCommand`/`findCac` alone only ever
|
|
8
|
+
* catch the minority that do.
|
|
9
|
+
*
|
|
10
|
+
* How: Node caches a CommonJS module by its resolved absolute path.
|
|
11
|
+
* Resolving `packageName` from the *target file's own directory* (not
|
|
12
|
+
* this package's - a different copy, per commander.adapter.ts's
|
|
13
|
+
* `looksLikeCommand` doc) and mutating the exact object at that cached
|
|
14
|
+
* path means the target's own later `require(packageName)` - resolving
|
|
15
|
+
* to the same path - returns this already-patched object. There's no
|
|
16
|
+
* way for the target to tell the difference: `new Command()` still
|
|
17
|
+
* returns a real `Command` (the patch is a construct-trapping `Proxy`
|
|
18
|
+
* around the real class, transparent to `instanceof` and to every
|
|
19
|
+
* static/instance member), it's just also recorded here as a side
|
|
20
|
+
* effect.
|
|
21
|
+
*
|
|
22
|
+
* Only reaches CommonJS construction - a direct `new ClassExport()`, or
|
|
23
|
+
* a named factory function that closes over the real class and returns
|
|
24
|
+
* `new ClassExport()` internally (commander's `createCommand`, cac's
|
|
25
|
+
* `cac()` - patching only the class export wouldn't catch these, since
|
|
26
|
+
* the factory's own closure still points at the *original* class, not
|
|
27
|
+
* whatever we later put in `moduleExports[classExportName]`). A target
|
|
28
|
+
* that's genuine ESM, reaching the framework via a static `import`
|
|
29
|
+
* rather than `require`, isn't reachable this way - Node's ESM module
|
|
30
|
+
* cache is separate and not patchable from CommonJS. That's a real,
|
|
31
|
+
* documented limit, not a bug: commander and cac both ship CJS-only, so
|
|
32
|
+
* even an ESM target almost always reaches them through interop's own
|
|
33
|
+
* require() underneath - the case this can't reach is the rare one.
|
|
34
|
+
*/
|
|
35
|
+
export declare function captureConstructions(packageName: string, entryPath: string, classExportName: string, factoryExportNames: readonly string[]): unknown[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.captureConstructions = captureConstructions;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
/**
|
|
6
|
+
* Captures whatever instance a target file's own top-level code
|
|
7
|
+
* constructs from `packageName` - even when the target never exports
|
|
8
|
+
* that instance anywhere `module.exports` reaches. Many real CLIs build
|
|
9
|
+
* their Commander/CAC instance inside a function that only runs when
|
|
10
|
+
* something actually calls it (or just never bother exporting it, since
|
|
11
|
+
* they have no reason to) - `findCommand`/`findCac` alone only ever
|
|
12
|
+
* catch the minority that do.
|
|
13
|
+
*
|
|
14
|
+
* How: Node caches a CommonJS module by its resolved absolute path.
|
|
15
|
+
* Resolving `packageName` from the *target file's own directory* (not
|
|
16
|
+
* this package's - a different copy, per commander.adapter.ts's
|
|
17
|
+
* `looksLikeCommand` doc) and mutating the exact object at that cached
|
|
18
|
+
* path means the target's own later `require(packageName)` - resolving
|
|
19
|
+
* to the same path - returns this already-patched object. There's no
|
|
20
|
+
* way for the target to tell the difference: `new Command()` still
|
|
21
|
+
* returns a real `Command` (the patch is a construct-trapping `Proxy`
|
|
22
|
+
* around the real class, transparent to `instanceof` and to every
|
|
23
|
+
* static/instance member), it's just also recorded here as a side
|
|
24
|
+
* effect.
|
|
25
|
+
*
|
|
26
|
+
* Only reaches CommonJS construction - a direct `new ClassExport()`, or
|
|
27
|
+
* a named factory function that closes over the real class and returns
|
|
28
|
+
* `new ClassExport()` internally (commander's `createCommand`, cac's
|
|
29
|
+
* `cac()` - patching only the class export wouldn't catch these, since
|
|
30
|
+
* the factory's own closure still points at the *original* class, not
|
|
31
|
+
* whatever we later put in `moduleExports[classExportName]`). A target
|
|
32
|
+
* that's genuine ESM, reaching the framework via a static `import`
|
|
33
|
+
* rather than `require`, isn't reachable this way - Node's ESM module
|
|
34
|
+
* cache is separate and not patchable from CommonJS. That's a real,
|
|
35
|
+
* documented limit, not a bug: commander and cac both ship CJS-only, so
|
|
36
|
+
* even an ESM target almost always reaches them through interop's own
|
|
37
|
+
* require() underneath - the case this can't reach is the rare one.
|
|
38
|
+
*/
|
|
39
|
+
function captureConstructions(packageName, entryPath, classExportName, factoryExportNames) {
|
|
40
|
+
const captured = [];
|
|
41
|
+
let moduleExports;
|
|
42
|
+
try {
|
|
43
|
+
const targetDir = (0, path_1.dirname)((0, path_1.resolve)(process.cwd(), entryPath));
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- resolving/patching the target's own copy of a CJS package, not a static dependency of this file
|
|
45
|
+
const resolvedPath = require.resolve(packageName, { paths: [targetDir] });
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- see above
|
|
47
|
+
moduleExports = require(resolvedPath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// The target doesn't have this package resolvable from its own
|
|
51
|
+
// location at all - nothing to patch, nothing to capture. The
|
|
52
|
+
// caller's existing "no instance found" error already covers this.
|
|
53
|
+
return captured;
|
|
54
|
+
}
|
|
55
|
+
const record = (instance) => {
|
|
56
|
+
if (instance && typeof instance === "object")
|
|
57
|
+
captured.push(instance);
|
|
58
|
+
};
|
|
59
|
+
const RealClass = moduleExports[classExportName];
|
|
60
|
+
if (typeof RealClass === "function") {
|
|
61
|
+
moduleExports[classExportName] = new Proxy(RealClass, {
|
|
62
|
+
construct(target, args, newTarget) {
|
|
63
|
+
const instance = Reflect.construct(target, args, newTarget);
|
|
64
|
+
record(instance);
|
|
65
|
+
return instance;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const factoryName of factoryExportNames) {
|
|
70
|
+
const realFactory = moduleExports[factoryName];
|
|
71
|
+
if (typeof realFactory === "function") {
|
|
72
|
+
moduleExports[factoryName] = (...args) => {
|
|
73
|
+
const instance = realFactory(...args);
|
|
74
|
+
record(instance);
|
|
75
|
+
return instance;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return captured;
|
|
80
|
+
}
|
package/dist/bin.js
CHANGED
|
@@ -45,9 +45,13 @@ program
|
|
|
45
45
|
console.warn(`A contract already exists. Run "cliguard update" to overwrite it.`);
|
|
46
46
|
process.exit(1);
|
|
47
47
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
49
|
+
const contract = await resolveAdapter(options.adapter).extract(entry);
|
|
50
|
+
(0, storage_1.writeContract)(contract);
|
|
51
|
+
console.log(`✅ CLI contract initialized successfully at ${(0, storage_1.getContractDisplayPath)()}.`);
|
|
52
|
+
return 0;
|
|
53
|
+
});
|
|
54
|
+
process.exit(exitCode);
|
|
51
55
|
});
|
|
52
56
|
program
|
|
53
57
|
.command("check")
|
|
@@ -56,20 +60,23 @@ program
|
|
|
56
60
|
.option(...adapterOption)
|
|
57
61
|
.option("--json", "print a machine-readable JSON result instead of text", false)
|
|
58
62
|
.action(async (entry, options) => {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
64
|
+
const oldContract = (0, storage_1.readContract)();
|
|
65
|
+
const newContract = await resolveAdapter(options.adapter).extract(entry);
|
|
66
|
+
const diff = diffEngine.compare(oldContract, newContract);
|
|
67
|
+
const hasBreaking = diff.some((entry) => entry.type === types_1.ChangeType.BREAKING);
|
|
68
|
+
if (options.json) {
|
|
69
|
+
console.log(JSON.stringify(toJsonResult(diff), null, 2));
|
|
70
|
+
return hasBreaking ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
if (diff.length === 0) {
|
|
73
|
+
console.log("✅ CLI contract is intact.");
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
printDiff(diff);
|
|
77
|
+
return hasBreaking ? 1 : 0;
|
|
78
|
+
});
|
|
79
|
+
process.exit(exitCode);
|
|
73
80
|
});
|
|
74
81
|
program
|
|
75
82
|
.command("update")
|
|
@@ -77,10 +84,45 @@ program
|
|
|
77
84
|
.argument("<entry>", "path to the target CLI's entry file")
|
|
78
85
|
.option(...adapterOption)
|
|
79
86
|
.action(async (entry, options) => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
const exitCode = await withSuppressedExit(async () => {
|
|
88
|
+
const contract = await resolveAdapter(options.adapter).extract(entry);
|
|
89
|
+
(0, storage_1.writeContract)(contract);
|
|
90
|
+
console.log("🔄 CLI contract updated successfully.");
|
|
91
|
+
return 0;
|
|
92
|
+
});
|
|
93
|
+
process.exit(exitCode);
|
|
83
94
|
});
|
|
95
|
+
/**
|
|
96
|
+
* Runs `action` with `process.exit` neutralized, restoring the real one
|
|
97
|
+
* the instant `action` settles - then the caller calls the *real*
|
|
98
|
+
* `process.exit` immediately with cliguard's own, correct code.
|
|
99
|
+
*
|
|
100
|
+
* Why this exists: the fallback in commander.adapter.ts /
|
|
101
|
+
* cac.adapter.ts's construction-capture lets a target CLI's own
|
|
102
|
+
* top-level code run further than a plain export lookup ever did - real
|
|
103
|
+
* targets often call `.parse()`/`.run()` for real as a side effect of
|
|
104
|
+
* being loaded, sometimes asynchronously (an `await` inside their own
|
|
105
|
+
* main function, a dangling `.catch()` continuation still in flight).
|
|
106
|
+
* Node's `process.exit()` is immediate and unconditional - if that
|
|
107
|
+
* dangling target code calls it (even with an unrelated code, even
|
|
108
|
+
* *after* cliguard already computed the right answer), it kills this
|
|
109
|
+
* process with the target's exit code, not cliguard's. Restoring the
|
|
110
|
+
* real `process.exit` and calling it ourselves right away, synchronously,
|
|
111
|
+
* the moment `action` resolves closes the race: Node's exit is immediate
|
|
112
|
+
* and single-threaded, so nothing queued after that point - including
|
|
113
|
+
* whatever the target's own code was about to do - ever runs.
|
|
114
|
+
*/
|
|
115
|
+
async function withSuppressedExit(action) {
|
|
116
|
+
const realExit = process.exit.bind(process);
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function -- deliberately a no-op: see doc comment above
|
|
118
|
+
process.exit = (() => undefined);
|
|
119
|
+
try {
|
|
120
|
+
return await action();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
process.exit = realExit;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
84
126
|
function toJsonResult(diff) {
|
|
85
127
|
const summary = {
|
|
86
128
|
breaking: diff.filter((entry) => entry.type === types_1.ChangeType.BREAKING).length,
|
|
@@ -17,6 +17,8 @@ export declare class DiffEngine {
|
|
|
17
17
|
compare(oldContract: Contract, newContract: Contract): DiffResult[];
|
|
18
18
|
private compareCommands;
|
|
19
19
|
private compareSubcommands;
|
|
20
|
+
/** CAC's default command (declared with no leading name, e.g. `cli.command("[...files]", ...)`) has name === "" - a blank path segment reads as a typo, not a real command. */
|
|
21
|
+
private commandLabel;
|
|
20
22
|
private compareOptions;
|
|
21
23
|
private compareOption;
|
|
22
24
|
private compareArguments;
|
package/dist/core/diff.engine.js
CHANGED
|
@@ -35,10 +35,10 @@ class DiffEngine {
|
|
|
35
35
|
results.push({
|
|
36
36
|
type: types_1.ChangeType.PATCH,
|
|
37
37
|
path,
|
|
38
|
-
message: `Description changed for command "${oldCmd.name}".`,
|
|
38
|
+
message: `Description changed for command "${this.commandLabel(oldCmd.name)}".`,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
results.push(...this.compareAliases(oldCmd.aliases, newCmd.aliases, path, `command "${oldCmd.name}"`));
|
|
41
|
+
results.push(...this.compareAliases(oldCmd.aliases, newCmd.aliases, path, `command "${this.commandLabel(oldCmd.name)}"`));
|
|
42
42
|
results.push(...this.compareOptions(oldCmd.options, newCmd.options, path));
|
|
43
43
|
results.push(...this.compareArguments(oldCmd.arguments, newCmd.arguments, path));
|
|
44
44
|
results.push(...this.compareSubcommands(oldCmd.subcommands, newCmd.subcommands, path));
|
|
@@ -49,13 +49,13 @@ class DiffEngine {
|
|
|
49
49
|
const oldByName = this.indexByName(oldSubs);
|
|
50
50
|
const newByName = this.indexByName(newSubs);
|
|
51
51
|
for (const [name, oldSub] of oldByName) {
|
|
52
|
-
const childPath = `${path} -> ${name}`;
|
|
52
|
+
const childPath = `${path} -> ${this.commandLabel(name)}`;
|
|
53
53
|
const newSub = newByName.get(name);
|
|
54
54
|
if (!newSub) {
|
|
55
55
|
results.push({
|
|
56
56
|
type: types_1.ChangeType.BREAKING,
|
|
57
57
|
path: childPath,
|
|
58
|
-
message: `Command "${name}" was removed.`,
|
|
58
|
+
message: `Command "${this.commandLabel(name)}" was removed.`,
|
|
59
59
|
});
|
|
60
60
|
continue;
|
|
61
61
|
}
|
|
@@ -66,12 +66,16 @@ class DiffEngine {
|
|
|
66
66
|
continue;
|
|
67
67
|
results.push({
|
|
68
68
|
type: types_1.ChangeType.ADDITIVE,
|
|
69
|
-
path: `${path} -> ${name}`,
|
|
70
|
-
message: `Command "${name}" was added.`,
|
|
69
|
+
path: `${path} -> ${this.commandLabel(name)}`,
|
|
70
|
+
message: `Command "${this.commandLabel(name)}" was added.`,
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
return results;
|
|
74
74
|
}
|
|
75
|
+
/** CAC's default command (declared with no leading name, e.g. `cli.command("[...files]", ...)`) has name === "" - a blank path segment reads as a typo, not a real command. */
|
|
76
|
+
commandLabel(name) {
|
|
77
|
+
return name === "" ? "<default>" : name;
|
|
78
|
+
}
|
|
75
79
|
compareOptions(oldOptions, newOptions, path) {
|
|
76
80
|
const results = [];
|
|
77
81
|
const oldByName = this.indexByName(oldOptions);
|