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.
Files changed (31) hide show
  1. package/dist/src/commands/add.js +18 -1
  2. package/dist/src/commands/init.js +11 -2
  3. package/dist/src/commands/sensors/coverage/contract.js +156 -0
  4. package/dist/src/commands/sensors/coverage/evaluate.js +107 -0
  5. package/dist/src/commands/sensors/coverage/evidence.js +118 -0
  6. package/dist/src/commands/sensors/coverage/index.js +31 -0
  7. package/dist/src/commands/sensors/coverage/render.js +152 -0
  8. package/dist/src/commands/sensors/coverage/resolve.js +126 -0
  9. package/dist/src/commands/sensors/index.js +16 -0
  10. package/dist/src/core/provider-version.js +26 -2
  11. package/dist/src/index.js +10 -5
  12. package/dist/src/utils/config.js +17 -0
  13. package/dist/tests/commands/add.test.js +42 -0
  14. package/dist/tests/commands/init.test.js +23 -0
  15. package/dist/tests/commands/sensors/coverage/contract.test.js +101 -0
  16. package/dist/tests/commands/sensors/coverage/evaluate.test.js +82 -0
  17. package/dist/tests/commands/sensors/coverage/evidence.test.js +251 -0
  18. package/dist/tests/commands/sensors/coverage/index.test.js +30 -0
  19. package/dist/tests/commands/sensors/coverage/render.test.js +149 -0
  20. package/dist/tests/commands/sensors/coverage/resolve.test.js +130 -0
  21. package/dist/tests/commands/sensors/index.test.js +52 -1
  22. package/dist/tests/commands/sensors/router.test.js +2 -1
  23. package/dist/tests/core/provider-version.test.js +41 -0
  24. package/dist/tests/integration/codex-provider-isolated.test.js +15 -1
  25. package/dist/tests/integration/copilot-init-isolated.test.js +1 -0
  26. package/dist/tests/integration/sensor-coverage-provider-evidence.test.js +88 -0
  27. package/dist/tests/integration/sensor-coverage.e2e.test.js +113 -0
  28. package/dist/tests/structural/jest-environment-is-isolated.test.js +14 -0
  29. package/dist/tests/structural/sensor-configs-are-present.test.js +10 -0
  30. package/dist/tests/utils/config.test.js +21 -0
  31. 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 code = error.code;
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
- if (code === 'ENOENT') {
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
- if (options.scope !== 'local' && options.scope !== 'global') {
539
- console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
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 = options.scope;
547
+ scopeVal = resolved.scope;
543
548
  }
544
549
  else {
545
550
  const scopeChoice = await (0, prompts_1.select)({
@@ -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
+ });