agentic-workflow-manager 6.4.1 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/add.js +18 -1
- package/dist/src/commands/init.js +11 -2
- package/dist/src/commands/sensors/coverage/contract.js +156 -0
- package/dist/src/commands/sensors/coverage/evaluate.js +107 -0
- package/dist/src/commands/sensors/coverage/evidence.js +118 -0
- package/dist/src/commands/sensors/coverage/index.js +31 -0
- package/dist/src/commands/sensors/coverage/render.js +152 -0
- package/dist/src/commands/sensors/coverage/resolve.js +126 -0
- package/dist/src/commands/sensors/index.js +16 -0
- package/dist/src/core/provider-version.js +26 -2
- package/dist/src/index.js +10 -5
- package/dist/src/utils/config.js +17 -0
- package/dist/tests/commands/add.test.js +42 -0
- package/dist/tests/commands/init.test.js +23 -0
- package/dist/tests/commands/sensors/coverage/contract.test.js +101 -0
- package/dist/tests/commands/sensors/coverage/evaluate.test.js +82 -0
- package/dist/tests/commands/sensors/coverage/evidence.test.js +251 -0
- package/dist/tests/commands/sensors/coverage/index.test.js +30 -0
- package/dist/tests/commands/sensors/coverage/render.test.js +149 -0
- package/dist/tests/commands/sensors/coverage/resolve.test.js +130 -0
- package/dist/tests/commands/sensors/index.test.js +52 -1
- package/dist/tests/commands/sensors/router.test.js +2 -1
- package/dist/tests/core/provider-version.test.js +41 -0
- package/dist/tests/integration/codex-provider-isolated.test.js +15 -1
- package/dist/tests/integration/copilot-init-isolated.test.js +1 -0
- package/dist/tests/integration/sensor-coverage-provider-evidence.test.js +88 -0
- package/dist/tests/integration/sensor-coverage.e2e.test.js +113 -0
- package/dist/tests/structural/jest-environment-is-isolated.test.js +14 -0
- package/dist/tests/structural/sensor-configs-are-present.test.js +10 -0
- package/dist/tests/utils/config.test.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.readBoundedJson = readBoundedJson;
|
|
7
|
+
exports.resolveCoverageInputs = resolveCoverageInputs;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const registries_1 = require("../../../core/registries");
|
|
11
|
+
const contract_1 = require("./contract");
|
|
12
|
+
function readFailure(file, error) {
|
|
13
|
+
return new Error(`Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
/** Read JSON from a regular, bounded file without ever following a symlink. */
|
|
16
|
+
function readBoundedJson(file) {
|
|
17
|
+
if (typeof file !== 'string' || file.trim().length === 0)
|
|
18
|
+
throw new Error('readBoundedJson: file must be a non-empty string');
|
|
19
|
+
let listed;
|
|
20
|
+
try {
|
|
21
|
+
listed = fs_1.default.lstatSync(file);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw readFailure(file, error);
|
|
25
|
+
}
|
|
26
|
+
if (!listed.isFile() || listed.isSymbolicLink())
|
|
27
|
+
throw new Error(`Cannot read ${file}: expected a regular file`);
|
|
28
|
+
if (listed.size > contract_1.MAX_COVERAGE_FILE_BYTES)
|
|
29
|
+
throw new Error(`Cannot read ${file}: exceeds 1 MiB limit`);
|
|
30
|
+
const noFollow = fs_1.default.constants.O_NOFOLLOW;
|
|
31
|
+
if (typeof noFollow !== 'number')
|
|
32
|
+
throw new Error(`Cannot read ${file}: platform cannot guarantee no symlink dereference`);
|
|
33
|
+
let descriptor;
|
|
34
|
+
let content;
|
|
35
|
+
try {
|
|
36
|
+
descriptor = fs_1.default.openSync(file, fs_1.default.constants.O_RDONLY | noFollow);
|
|
37
|
+
const opened = fs_1.default.fstatSync(descriptor);
|
|
38
|
+
if (!opened.isFile() || opened.size > contract_1.MAX_COVERAGE_FILE_BYTES) {
|
|
39
|
+
throw new Error('expected a regular file within the 1 MiB limit');
|
|
40
|
+
}
|
|
41
|
+
const buffer = Buffer.allocUnsafe(contract_1.MAX_COVERAGE_FILE_BYTES + 1);
|
|
42
|
+
const count = fs_1.default.readSync(descriptor, buffer, 0, buffer.length, null);
|
|
43
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > contract_1.MAX_COVERAGE_FILE_BYTES) {
|
|
44
|
+
throw new Error('exceeds 1 MiB limit');
|
|
45
|
+
}
|
|
46
|
+
content = buffer.subarray(0, count).toString('utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw readFailure(file, error);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (descriptor !== undefined)
|
|
53
|
+
fs_1.default.closeSync(descriptor);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(content);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new Error(`Invalid JSON at ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function readPackEnvelope(input, file, expectedName) {
|
|
63
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input))
|
|
64
|
+
throw new Error(`Invalid pack at ${file}: expected object`);
|
|
65
|
+
const pack = input;
|
|
66
|
+
if (typeof pack.name !== 'string' || pack.name !== expectedName) {
|
|
67
|
+
throw new Error(`Invalid pack at ${file}: name must equal '${expectedName}'`);
|
|
68
|
+
}
|
|
69
|
+
if (typeof pack.sensors !== 'object' || pack.sensors === null || Array.isArray(pack.sensors)) {
|
|
70
|
+
throw new Error(`Invalid pack at ${file}: sensors must be an object`);
|
|
71
|
+
}
|
|
72
|
+
return 'coverage' in pack ? { coverage: pack.coverage } : {};
|
|
73
|
+
}
|
|
74
|
+
function safeRegistryName(name) {
|
|
75
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || name.includes('..')) {
|
|
76
|
+
throw new Error(`Invalid registry name '${name}': expected a safe path component`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Finds the nearest manifest without following a symlink during discovery. */
|
|
80
|
+
function findManifestDirNoFollow(startCwd) {
|
|
81
|
+
let dir = path_1.default.resolve(startCwd);
|
|
82
|
+
while (true) {
|
|
83
|
+
const manifestPath = path_1.default.join(dir, '.awm', 'sensors.json');
|
|
84
|
+
try {
|
|
85
|
+
fs_1.default.lstatSync(manifestPath);
|
|
86
|
+
return dir;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== 'ENOENT')
|
|
90
|
+
throw readFailure(manifestPath, error);
|
|
91
|
+
}
|
|
92
|
+
const parent = path_1.default.dirname(dir);
|
|
93
|
+
if (parent === dir)
|
|
94
|
+
return null;
|
|
95
|
+
dir = parent;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function resolveCoverageInputs(cwd) {
|
|
99
|
+
if (typeof cwd !== 'string' || cwd.trim().length === 0)
|
|
100
|
+
throw new Error('resolveCoverageInputs: cwd must be a non-empty string');
|
|
101
|
+
const projectRoot = findManifestDirNoFollow(cwd);
|
|
102
|
+
if (!projectRoot)
|
|
103
|
+
return { kind: 'not_configured' };
|
|
104
|
+
const manifestPath = path_1.default.join(projectRoot, '.awm', 'sensors.json');
|
|
105
|
+
const manifest = (0, contract_1.parseCoverageManifest)(readBoundedJson(manifestPath), manifestPath);
|
|
106
|
+
for (const registry of (0, registries_1.listRegistries)()) {
|
|
107
|
+
safeRegistryName(registry.name);
|
|
108
|
+
const packPath = path_1.default.join(registry.contentRoot, 'sensor-packs', manifest.pack, 'pack.json');
|
|
109
|
+
try {
|
|
110
|
+
fs_1.default.lstatSync(packPath);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code === 'ENOENT')
|
|
114
|
+
continue;
|
|
115
|
+
throw readFailure(packPath, error);
|
|
116
|
+
}
|
|
117
|
+
const { coverage } = readPackEnvelope(readBoundedJson(packPath), packPath, manifest.pack);
|
|
118
|
+
if (coverage === undefined)
|
|
119
|
+
return { kind: 'no_reference', projectRoot, pack: manifest.pack, registry: registry.name, manifest };
|
|
120
|
+
return {
|
|
121
|
+
kind: 'ready', projectRoot, pack: manifest.pack, registry: registry.name,
|
|
122
|
+
manifest, contract: (0, contract_1.parseCoverageContract)(coverage, packPath),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`Pack '${manifest.pack}' was not found in configured registries`);
|
|
126
|
+
}
|
|
@@ -12,6 +12,8 @@ const init_1 = require("./init");
|
|
|
12
12
|
const status_1 = require("./status");
|
|
13
13
|
const install_1 = require("./install");
|
|
14
14
|
const baseline_1 = require("./baseline");
|
|
15
|
+
const coverage_1 = require("./coverage");
|
|
16
|
+
const render_1 = require("./coverage/render");
|
|
15
17
|
const registries_1 = require("../../core/registries");
|
|
16
18
|
/** Map a sensor run verdict to a process exit code. fail → 1; everything else → 0.
|
|
17
19
|
* not_certified intentionally exits 0: its signal lives in `overall`, because
|
|
@@ -21,6 +23,20 @@ function exitCodeFor(output) {
|
|
|
21
23
|
}
|
|
22
24
|
function registerSensorsCommand(program) {
|
|
23
25
|
const sensors = program.command('sensors').description('manage computational sensors for the current project');
|
|
26
|
+
sensors
|
|
27
|
+
.command('coverage')
|
|
28
|
+
.description('report static gaps between configured sensors and the pack reference')
|
|
29
|
+
.option('--json', 'emit the versioned machine-readable envelope')
|
|
30
|
+
.action((opts) => {
|
|
31
|
+
try {
|
|
32
|
+
const report = (0, coverage_1.runCoverage)(process.cwd());
|
|
33
|
+
process.stdout.write(opts.json ? (0, render_1.renderCoverageJson)(report) : (0, render_1.renderCoverageHuman)(report));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
prompts_1.log.error(error instanceof Error ? error.message : String(error));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
24
40
|
sensors
|
|
25
41
|
.command('run')
|
|
26
42
|
.description('run sensors from .awm/sensors.json')
|
|
@@ -4,6 +4,7 @@ exports.assertProviderSupported = assertProviderSupported;
|
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
5
|
const providers_1 = require("../providers");
|
|
6
6
|
const versioning_1 = require("./versioning");
|
|
7
|
+
const paths_1 = require("./paths");
|
|
7
8
|
function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
|
|
8
9
|
const provider = (0, providers_1.providerFor)(agent);
|
|
9
10
|
if (!provider.versionCommand || !provider.minimumVersion) {
|
|
@@ -15,17 +16,40 @@ function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
|
|
|
15
16
|
encoding: 'utf8',
|
|
16
17
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
17
18
|
timeout: 5000,
|
|
19
|
+
// Windows can't CreateProcess a `.cmd` shim directly (npm installs
|
|
20
|
+
// `codex` as `codex.cmd`, not `codex.exe`) — execFileSync needs a
|
|
21
|
+
// shell to resolve and run it, or it throws ENOENT even though
|
|
22
|
+
// typing `codex --version` in the same shell works fine. Safe here
|
|
23
|
+
// (unlike sensors.json's `cmd`, core/paths.ts's resolveOnPath):
|
|
24
|
+
// `provider.versionCommand.command`/`args` are hardcoded first-party
|
|
25
|
+
// config (providers/index.ts), never attacker-controlled input.
|
|
26
|
+
// Found running the issue #55 Windows playbook: `awm init -a codex`
|
|
27
|
+
// reported "Codex is not installed" on a machine where it plainly
|
|
28
|
+
// was — `codex --version` worked fine typed directly.
|
|
29
|
+
shell: (0, paths_1.isWindowsNative)(),
|
|
18
30
|
}).toString();
|
|
19
31
|
}
|
|
20
32
|
catch (error) {
|
|
21
|
-
const
|
|
33
|
+
const err = error;
|
|
22
34
|
// `provider.label` y `versionCommand`, no "Codex" literal. Esta funcion es
|
|
23
35
|
// generica sobre AgentTarget desde siempre, pero cada mensaje y el patron de
|
|
24
36
|
// parseo nombraban al unico provider que hoy declara `versionCommand` — el
|
|
25
37
|
// segundo en declararlo habria reportado "Codex no esta instalado" al no
|
|
26
38
|
// encontrar SU binario, y habria fallado a parsear una salida perfectamente
|
|
27
39
|
// valida contra el formato de otro programa.
|
|
28
|
-
|
|
40
|
+
//
|
|
41
|
+
// Two distinct "not found" shapes to catch now that Windows goes through
|
|
42
|
+
// a shell (see `shell: isWindowsNative()` above): without a shell, a
|
|
43
|
+
// missing binary is a spawn-level ENOENT; through cmd.exe, the shell
|
|
44
|
+
// itself spawns fine and the missing command instead surfaces as a
|
|
45
|
+
// non-zero exit with "'codex' is not recognized..." on stderr — no
|
|
46
|
+
// ENOENT anywhere. Missing this second shape was a real regression:
|
|
47
|
+
// windows-latest CI (no codex installed at all) started reporting
|
|
48
|
+
// "version probe failed" instead of "not installed", because the exit
|
|
49
|
+
// code alone doesn't say WHY the shell failed.
|
|
50
|
+
const shellCommandNotFound = (0, paths_1.isWindowsNative)()
|
|
51
|
+
&& /is not recognized as an internal or external command/i.test(String(err.stderr ?? ''));
|
|
52
|
+
if (err.code === 'ENOENT' || shellCommandNotFound) {
|
|
29
53
|
throw new Error(`${provider.label} is not installed or not available on PATH ` +
|
|
30
54
|
`(tried \`${provider.versionCommand.command}\`). Install it, then re-run.`);
|
|
31
55
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -122,7 +122,7 @@ program.command('add [name]')
|
|
|
122
122
|
if (name) {
|
|
123
123
|
const allBundles = (0, bundles_1.discoverAllBundles)();
|
|
124
124
|
const prefs = (0, config_1.getPreferences)();
|
|
125
|
-
const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope }, prefs, allBundles);
|
|
125
|
+
const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope, method: options.method }, prefs, allBundles);
|
|
126
126
|
if (outcome.code !== 0)
|
|
127
127
|
process.exit(outcome.code);
|
|
128
128
|
(0, prompts_1.outro)('Done.');
|
|
@@ -534,12 +534,17 @@ program.command('remove [name]')
|
|
|
534
534
|
targetAgents = agentChoice;
|
|
535
535
|
}
|
|
536
536
|
let scopeVal;
|
|
537
|
-
if (options.scope) {
|
|
538
|
-
|
|
539
|
-
|
|
537
|
+
if (options.scope || options.yes) {
|
|
538
|
+
// Same reasoning as the --agent default just above: `--yes` means ZERO
|
|
539
|
+
// prompts. Without the `options.yes` half of this condition, `awm remove
|
|
540
|
+
// <bundle> --yes` still opened the scope picker and hung any
|
|
541
|
+
// non-interactive caller — the flag promised no-interactive and wasn't.
|
|
542
|
+
const resolved = (0, config_1.resolveScopeOption)(options.scope, prefs.defaultScope);
|
|
543
|
+
if (!resolved.ok) {
|
|
544
|
+
console.error(picocolors_1.default.red(resolved.error));
|
|
540
545
|
process.exit(1);
|
|
541
546
|
}
|
|
542
|
-
scopeVal =
|
|
547
|
+
scopeVal = resolved.scope;
|
|
543
548
|
}
|
|
544
549
|
else {
|
|
545
550
|
const scopeChoice = await (0, prompts_1.select)({
|
package/dist/src/utils/config.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.getPreferences = getPreferences;
|
|
|
9
9
|
exports.loadPreferences = loadPreferences;
|
|
10
10
|
exports.savePreferences = savePreferences;
|
|
11
11
|
exports.enableAgent = enableAgent;
|
|
12
|
+
exports.resolveScopeOption = resolveScopeOption;
|
|
12
13
|
// src/utils/config.ts
|
|
13
14
|
const fs_1 = __importDefault(require("fs"));
|
|
14
15
|
const path_1 = __importDefault(require("path"));
|
|
@@ -158,3 +159,19 @@ function enableAgent(prefs, agent) {
|
|
|
158
159
|
? prefs
|
|
159
160
|
: { ...prefs, enabledAgents: [...prefs.enabledAgents, agent] };
|
|
160
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve `--scope` the same way across every non-interactive command path: an
|
|
164
|
+
* explicit value wins (validated), otherwise fall back to the caller's default
|
|
165
|
+
* instead of prompting. Pulled out of `remove`'s Commander closure (D-006) after
|
|
166
|
+
* `--yes` was found to still open the scope picker with no `--scope` given —
|
|
167
|
+
* `--yes` means zero prompts, and this is the one call site that had drifted
|
|
168
|
+
* from that rule (the sibling --agent default sits right next to it in index.ts).
|
|
169
|
+
*/
|
|
170
|
+
function resolveScopeOption(explicit, fallback) {
|
|
171
|
+
if (explicit === undefined)
|
|
172
|
+
return { ok: true, scope: fallback };
|
|
173
|
+
if (explicit !== 'local' && explicit !== 'global') {
|
|
174
|
+
return { ok: false, error: `Invalid scope "${explicit}". Use: local or global.` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, scope: explicit };
|
|
177
|
+
}
|
|
@@ -94,3 +94,45 @@ it('awm add demo materializes a real Copilot .instructions.md file end-to-end',
|
|
|
94
94
|
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
95
95
|
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
96
96
|
});
|
|
97
|
+
// Regression for WIN-02 (issue #55 Windows playbook): `--method copy` was
|
|
98
|
+
// silently discarded by this exact function — `runAddBundleCore` hardcoded
|
|
99
|
+
// 'symlink' regardless of what the CLI parsed, so a real Windows machine got
|
|
100
|
+
// a Junction back no matter what `--method` asked for.
|
|
101
|
+
describe('runAddBundleCore --method', () => {
|
|
102
|
+
const claudeCodePrefs = {
|
|
103
|
+
defaultAgent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink', defaultScope: 'local',
|
|
104
|
+
};
|
|
105
|
+
it('honours an explicit --method copy: a real directory, not a symlink', () => {
|
|
106
|
+
const content = makeContentFixture();
|
|
107
|
+
const projectRoot = makeProjectRoot();
|
|
108
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
109
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'copy', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
110
|
+
expect(outcome.code).toBe(0);
|
|
111
|
+
const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
|
|
112
|
+
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
113
|
+
expect(fs_1.default.readFileSync(path_1.default.join(skillPath, 'SKILL.md'), 'utf8')).toContain('Follow the demo skill body.');
|
|
114
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
115
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
it('still defaults to symlink when --method is omitted (unchanged behavior)', () => {
|
|
118
|
+
const content = makeContentFixture();
|
|
119
|
+
const projectRoot = makeProjectRoot();
|
|
120
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
121
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
122
|
+
expect(outcome.code).toBe(0);
|
|
123
|
+
const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
|
|
124
|
+
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(true);
|
|
125
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
126
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
it('rejects an invalid --method value without touching the filesystem', () => {
|
|
129
|
+
const content = makeContentFixture();
|
|
130
|
+
const projectRoot = makeProjectRoot();
|
|
131
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
132
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'bogus', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
133
|
+
expect(outcome.code).toBe(1);
|
|
134
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude/skills/demo-skill'))).toBe(false);
|
|
135
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
136
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -108,6 +108,29 @@ describe('runInit', () => {
|
|
|
108
108
|
const caveatCalls = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
109
109
|
expect(caveatCalls).toHaveLength(0);
|
|
110
110
|
});
|
|
111
|
+
// Regression: `awm init --yes --json > init.json` on native Windows used to
|
|
112
|
+
// write the caveat to stdout via console.log AHEAD of the JSON payload,
|
|
113
|
+
// so the documented core-acceptance.md CORE-03 flow produced a file that
|
|
114
|
+
// wasn't valid JSON (banner text, then `{...}`). The caveat must still
|
|
115
|
+
// reach the operator — it now goes to stderr instead of vanishing.
|
|
116
|
+
it('does not pollute --json stdout with the caveat on native Windows', async () => {
|
|
117
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
118
|
+
const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
119
|
+
const { runInit } = require('../../src/commands/init');
|
|
120
|
+
await runInit({
|
|
121
|
+
cwd: tmpHome,
|
|
122
|
+
yes: true,
|
|
123
|
+
json: true,
|
|
124
|
+
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
125
|
+
});
|
|
126
|
+
const written = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
127
|
+
expect(() => JSON.parse(written)).not.toThrow();
|
|
128
|
+
const caveatOnStdout = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
129
|
+
expect(caveatOnStdout).toHaveLength(0);
|
|
130
|
+
const caveatOnStderr = errSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
131
|
+
expect(caveatOnStderr).toHaveLength(1);
|
|
132
|
+
errSpy.mockRestore();
|
|
133
|
+
});
|
|
111
134
|
});
|
|
112
135
|
it('returns exit 0 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
|
|
113
136
|
const { runInit } = require('../../src/commands/init');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const contract_1 = require("../../../../src/commands/sensors/coverage/contract");
|
|
4
|
+
describe('coverage contract v1', () => {
|
|
5
|
+
it('returns a complete valid contract unchanged', () => {
|
|
6
|
+
const input = {
|
|
7
|
+
schemaVersion: 1,
|
|
8
|
+
classes: {
|
|
9
|
+
'runtime-validation': {
|
|
10
|
+
description: 'All durable coverage artifacts validate their inputs.',
|
|
11
|
+
detectors: [{
|
|
12
|
+
sensor: 'contract-test',
|
|
13
|
+
evidence: {
|
|
14
|
+
commandIncludes: ['coverage'],
|
|
15
|
+
files: [{ path: 'contract.ts', containsAll: ['parseCoverageContract'] }],
|
|
16
|
+
},
|
|
17
|
+
}],
|
|
18
|
+
remedy: { summary: 'Add a parser.', command: 'npm test -- contract.test.ts' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
expect((0, contract_1.parseCoverageContract)(input, 'coverage.json')).toEqual(input);
|
|
23
|
+
});
|
|
24
|
+
test.each([
|
|
25
|
+
[{ schemaVersion: 2, classes: {} }, 'schemaVersion'],
|
|
26
|
+
[{ schemaVersion: 1, classes: {}, extra: true }, 'unknown field'],
|
|
27
|
+
[{ schemaVersion: 1, classes: {} }, 'classes'],
|
|
28
|
+
[{ schemaVersion: 1, classes: { Bad: { description: 'x', detectors: [{ sensor: 'test' }], remedy: { summary: 'x', command: 'x' } } } }, 'class'],
|
|
29
|
+
[{ schemaVersion: 1, classes: { valid: { description: '', detectors: [{ sensor: 'test' }], remedy: { summary: 'x', command: 'x' } } } }, 'description'],
|
|
30
|
+
[{ schemaVersion: 1, classes: { valid: { description: ' \t', detectors: [{ sensor: 'test' }], remedy: { summary: 'x', command: 'x' } } } }, 'description'],
|
|
31
|
+
[{ schemaVersion: 1, classes: { valid: { description: 'x', detectors: [], remedy: { summary: 'x', command: 'x' } } } }, 'detectors'],
|
|
32
|
+
])('rejects malformed contract %j', (input, message) => {
|
|
33
|
+
expect(() => (0, contract_1.parseCoverageContract)(input, 'coverage.json')).toThrow(message);
|
|
34
|
+
});
|
|
35
|
+
test.each(['', '.', '..', '../secret', 'a/../../secret', '/etc/passwd', 'C:\\secret', 'a\\..\\secret', ' report.txt', 'report!.txt'])('rejects hostile evidence path %p', (path) => {
|
|
36
|
+
const input = {
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
classes: {
|
|
39
|
+
valid: {
|
|
40
|
+
description: 'x',
|
|
41
|
+
detectors: [{ sensor: 'test', evidence: { files: [{ path, containsAll: [] }] } }],
|
|
42
|
+
remedy: { summary: 'x', command: 'x' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
expect(() => (0, contract_1.parseCoverageContract)(input, 'coverage.json')).toThrow('path');
|
|
47
|
+
});
|
|
48
|
+
it('rejects whitespace-only evidence text', () => {
|
|
49
|
+
const input = {
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
classes: {
|
|
52
|
+
valid: {
|
|
53
|
+
description: 'x',
|
|
54
|
+
detectors: [{ sensor: 'test', evidence: { files: [{ path: 'report.txt', containsAll: [' \n'] }] } }],
|
|
55
|
+
remedy: { summary: 'x', command: 'x' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
expect(() => (0, contract_1.parseCoverageContract)(input, 'coverage.json')).toThrow('containsAll');
|
|
60
|
+
});
|
|
61
|
+
it('rejects unknown nested evidence fields', () => {
|
|
62
|
+
const input = {
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
classes: {
|
|
65
|
+
valid: {
|
|
66
|
+
description: 'x',
|
|
67
|
+
detectors: [{ sensor: 'test', evidence: { commandInclude: ['coverage'] } }],
|
|
68
|
+
remedy: { summary: 'x', command: 'x' },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
expect(() => (0, contract_1.parseCoverageContract)(input, 'coverage.json')).toThrow('unknown field');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('coverage manifest boundary', () => {
|
|
76
|
+
it('accepts all legacy sensor fields', () => {
|
|
77
|
+
const input = {
|
|
78
|
+
pack: 'js-ts',
|
|
79
|
+
concurrency: 2,
|
|
80
|
+
sensors: {
|
|
81
|
+
lint: {
|
|
82
|
+
cmd: 'npm run lint', fast: true, enabled: true, timeout: 120, changedCmd: 'npm run lint -- {files}', changedExtensions: ['.ts'], formatter: 'eslint-llm',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
expect((0, contract_1.parseCoverageManifest)(input, 'sensors.json')).toEqual(input);
|
|
87
|
+
});
|
|
88
|
+
test.each([
|
|
89
|
+
[null, 'object'],
|
|
90
|
+
[{}, 'pack'],
|
|
91
|
+
[{ pack: '', sensors: {} }, 'pack'],
|
|
92
|
+
[{ pack: ' js-ts', sensors: {} }, 'pack'],
|
|
93
|
+
[{ pack: 'js ts', sensors: {} }, 'pack'],
|
|
94
|
+
[{ pack: 'js@ts', sensors: {} }, 'pack'],
|
|
95
|
+
[{ pack: 'js-ts', sensors: null }, 'sensors'],
|
|
96
|
+
[{ pack: 'js-ts', sensors: { lint: { cmd: 3 } } }, 'cmd'],
|
|
97
|
+
[{ pack: 'js-ts', sensors: { 'lint!': {} } }, 'sensor name'],
|
|
98
|
+
])('rejects malformed manifest %j', (input, message) => {
|
|
99
|
+
expect(() => (0, contract_1.parseCoverageManifest)(input, 'sensors.json')).toThrow(message);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const evaluate_1 = require("../../../../src/commands/sensors/coverage/evaluate");
|
|
4
|
+
const contract = {
|
|
5
|
+
schemaVersion: 1,
|
|
6
|
+
classes: {
|
|
7
|
+
alpha: {
|
|
8
|
+
description: 'Alpha',
|
|
9
|
+
detectors: [{ sensor: 'one' }, { sensor: 'two' }],
|
|
10
|
+
remedy: { summary: 'Fix alpha', command: 'fix alpha' },
|
|
11
|
+
},
|
|
12
|
+
zeta: {
|
|
13
|
+
description: 'Zeta',
|
|
14
|
+
detectors: [{ sensor: 'three' }],
|
|
15
|
+
remedy: { summary: 'Fix zeta', command: 'fix zeta' },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const observation = (classId, detectorIndex, sensor, status) => ({ classId, detectorIndex, sensor, status, evidence: [] });
|
|
20
|
+
describe('coverage evaluation', () => {
|
|
21
|
+
test.each([
|
|
22
|
+
[[observation('alpha', 0, 'one', 'covered'), observation('alpha', 1, 'two', 'missing')], 'covered'],
|
|
23
|
+
[[observation('alpha', 0, 'one', 'missing'), observation('alpha', 1, 'two', 'disabled')], 'missing'],
|
|
24
|
+
[[observation('alpha', 0, 'one', 'ineffective'), observation('alpha', 1, 'two', 'missing')], 'missing'],
|
|
25
|
+
[[observation('alpha', 0, 'one', 'unverifiable'), observation('alpha', 1, 'two', 'missing')], 'unverifiable'],
|
|
26
|
+
])('reduces detector alternatives %j to %s', (alpha, expected) => {
|
|
27
|
+
const result = (0, evaluate_1.evaluateCoverage)(contract, [...alpha, observation('zeta', 0, 'three', 'covered')]);
|
|
28
|
+
expect(result.classes.find((item) => item.id === 'alpha')?.status).toBe(expected);
|
|
29
|
+
});
|
|
30
|
+
it('makes global gaps outrank unverifiable while preserving both classes', () => {
|
|
31
|
+
const result = (0, evaluate_1.evaluateCoverage)(contract, [
|
|
32
|
+
observation('alpha', 0, 'one', 'unverifiable'),
|
|
33
|
+
observation('alpha', 1, 'two', 'missing'),
|
|
34
|
+
observation('zeta', 0, 'three', 'missing'),
|
|
35
|
+
]);
|
|
36
|
+
expect(result.overall).toBe('gaps');
|
|
37
|
+
expect(result.classes.map((item) => [item.id, item.status])).toEqual([
|
|
38
|
+
['alpha', 'unverifiable'],
|
|
39
|
+
['zeta', 'missing'],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
it('sorts classes by stable ID and is deterministic under reordered observations', () => {
|
|
43
|
+
const result = (0, evaluate_1.evaluateCoverage)(contract, [
|
|
44
|
+
observation('zeta', 0, 'three', 'covered'),
|
|
45
|
+
observation('alpha', 1, 'two', 'missing'),
|
|
46
|
+
observation('alpha', 0, 'one', 'covered'),
|
|
47
|
+
]);
|
|
48
|
+
expect(result.classes.map((item) => item.id)).toEqual(['alpha', 'zeta']);
|
|
49
|
+
expect((0, evaluate_1.evaluateCoverage)(contract, [
|
|
50
|
+
observation('alpha', 0, 'one', 'covered'),
|
|
51
|
+
observation('zeta', 0, 'three', 'covered'),
|
|
52
|
+
observation('alpha', 1, 'two', 'missing'),
|
|
53
|
+
])).toEqual(result);
|
|
54
|
+
});
|
|
55
|
+
it('fails loudly when an observation is missing or duplicated', () => {
|
|
56
|
+
expect(() => (0, evaluate_1.evaluateCoverage)(contract, [])).toThrow(/missing observation.*one/);
|
|
57
|
+
expect(() => (0, evaluate_1.evaluateCoverage)(contract, [
|
|
58
|
+
observation('alpha', 0, 'one', 'covered'),
|
|
59
|
+
observation('alpha', 0, 'one', 'covered'),
|
|
60
|
+
observation('alpha', 1, 'two', 'covered'),
|
|
61
|
+
observation('zeta', 0, 'three', 'covered'),
|
|
62
|
+
])).toThrow(/duplicate observation.*alpha:0/);
|
|
63
|
+
});
|
|
64
|
+
it('keeps alternatives with the same sensor independent by detector index', () => {
|
|
65
|
+
const sameSensor = {
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
classes: {
|
|
68
|
+
config: {
|
|
69
|
+
description: 'Project configuration',
|
|
70
|
+
detectors: [{ sensor: 'lint' }, { sensor: 'lint' }],
|
|
71
|
+
remedy: { summary: 'Add config', command: 'touch eslint.config.js' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const result = (0, evaluate_1.evaluateCoverage)(sameSensor, [
|
|
76
|
+
observation('config', 0, 'lint', 'ineffective'),
|
|
77
|
+
observation('config', 1, 'lint', 'covered'),
|
|
78
|
+
]);
|
|
79
|
+
expect(result.classes[0].status).toBe('covered');
|
|
80
|
+
expect(result.classes[0].detectors).toHaveLength(2);
|
|
81
|
+
});
|
|
82
|
+
});
|