agentic-workflow-manager 9.16.1 → 9.17.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 (34) hide show
  1. package/dist/src/commands/preflight/checks.js +63 -13
  2. package/dist/src/commands/sensors/bootstrap.js +2 -1
  3. package/dist/src/commands/sensors/compatibility/contract.js +30 -26
  4. package/dist/src/commands/sensors/compatibility/discovery.js +21 -0
  5. package/dist/src/commands/sensors/compatibility/manifest.js +169 -17
  6. package/dist/src/commands/sensors/compatibility/materialize.js +30 -5
  7. package/dist/src/commands/sensors/compatibility/source.js +3 -2
  8. package/dist/src/commands/sensors/coverage/render.js +8 -2
  9. package/dist/src/commands/sensors/coverage/resolve.js +7 -0
  10. package/dist/src/commands/sensors/init.js +15 -2
  11. package/dist/src/commands/sensors/migrate.js +13 -10
  12. package/dist/src/commands/sensors/prepare.js +53 -6
  13. package/dist/src/commands/sensors/project.js +60 -6
  14. package/dist/src/commands/sensors/result.js +18 -3
  15. package/dist/src/commands/sensors/run.js +124 -16
  16. package/dist/src/commands/sensors/status.js +163 -18
  17. package/dist/src/core/init/steps.js +5 -1
  18. package/dist/tests/commands/preflight/preflight.test.js +121 -0
  19. package/dist/tests/commands/sensors/bootstrap.test.js +10 -0
  20. package/dist/tests/commands/sensors/compatibility/manifest.test.js +204 -0
  21. package/dist/tests/commands/sensors/init.test.js +107 -0
  22. package/dist/tests/commands/sensors/migrate.test.js +4 -3
  23. package/dist/tests/commands/sensors/partial-pack-bootstrap.test.js +1 -1
  24. package/dist/tests/commands/sensors/prepare.test.js +62 -0
  25. package/dist/tests/commands/sensors/result.test.js +66 -0
  26. package/dist/tests/commands/sensors/run.test.js +331 -0
  27. package/dist/tests/commands/sensors/status.test.js +204 -0
  28. package/dist/tests/structural/native-release-artifacts.test.js +4 -2
  29. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +104 -0
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  32. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  33. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  34. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -14,6 +14,7 @@ const resolve_1 = require("./compatibility/resolve");
14
14
  const project_1 = require("./project");
15
15
  const source_1 = require("./compatibility/source");
16
16
  const registries_1 = require("../../core/registries");
17
+ const manifest_1 = require("./compatibility/manifest");
17
18
  /** First non-flag token after `npx` — the tool the command actually runs. */
18
19
  function npxTool(parts) {
19
20
  for (let i = 1; i < parts.length; i++) {
@@ -197,6 +198,90 @@ function invalidStatus(project, pack, reason = 'manifest-malformed', remedy = 'r
197
198
  ...projectMetadata(project, 'invalid', reason, { remedy }),
198
199
  };
199
200
  }
201
+ function hasSelectedPack(manifest) {
202
+ return typeof manifest.pack === 'string' && manifest.pack.length > 0;
203
+ }
204
+ function projectSensorCheck(sensor, cwd) {
205
+ if (sensor.enabled === false) {
206
+ return { ok: true, detail: 'disabled (project-declared)', certification: 'project-declared' };
207
+ }
208
+ const check = checkStructuredCommand(sensor.command, cwd, sensor.assets);
209
+ return {
210
+ ok: check.ok,
211
+ detail: check.ok ? `project-declared: ${check.detail}` : `${check.detail} (project-declared)`,
212
+ certification: 'project-declared',
213
+ };
214
+ }
215
+ function packBoundOnly(manifest) {
216
+ const sensors = Object.fromEntries(Object.entries(manifest.sensors).filter(([, sensor]) => !(0, manifest_1.isProjectDeclaredSensor)(sensor)));
217
+ const pack = 'pack' in manifest && typeof manifest.pack === 'string' ? manifest.pack : '';
218
+ return { schemaVersion: 2, pack, sensors };
219
+ }
220
+ /** Live pack sensor ids, or null when the pack cannot be resolved (fail closed). */
221
+ function livePackSensorIds(source) {
222
+ try {
223
+ const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
224
+ if (parsed.kind !== 'v2')
225
+ return null;
226
+ return new Set(Object.keys(parsed.pack.sensors));
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ /**
233
+ * RF-1.7 status: never label a colliding project id as project-declared READY.
234
+ * When pack is selected and live is unavailable, fail closed rather than claiming
235
+ * the project override is safe.
236
+ */
237
+ function projectDeclaredOrCollisionCheck(name, sensor, packageRoot, packSelected, liveIds) {
238
+ if (packSelected && (liveIds === null || liveIds.has(name))) {
239
+ return {
240
+ ok: false,
241
+ detail: liveIds === null
242
+ ? `project-sensor-name-collision: ${name} (live pack unavailable)`
243
+ : `project-sensor-name-collision: ${name}; pack sensor kept`,
244
+ };
245
+ }
246
+ return projectSensorCheck(sensor, packageRoot);
247
+ }
248
+ /** Soft-isolate valid siblings into checks while authority remains invalid (RF soft-parse). */
249
+ function softIsolatedChecks(project) {
250
+ const recovered = (0, project_1.softRecoverInvalidProject)(project);
251
+ if (!recovered)
252
+ return {};
253
+ const checks = {};
254
+ const v3Project = recovered.manifest.kind === 'v3' && recovered.manifest.pack.mode === 'project-sensors'
255
+ ? recovered.manifest.pack
256
+ : null;
257
+ const sensors = v3Project?.sensors ?? {};
258
+ // RF-1.7: same collision path as configured status — live pack probe when pack is selected.
259
+ const packSelected = v3Project !== null && hasSelectedPack(v3Project);
260
+ let liveIds = null;
261
+ if (packSelected) {
262
+ try {
263
+ const resolution = (0, source_1.resolveSensorSource)(recovered.manifest, { registries: (0, registries_1.listRegistries)() });
264
+ liveIds = 'source' in resolution ? livePackSensorIds(resolution.source) : null;
265
+ }
266
+ catch {
267
+ liveIds = null;
268
+ }
269
+ }
270
+ for (const [name, sensor] of Object.entries(sensors)) {
271
+ if ((0, manifest_1.isProjectDeclaredSensor)(sensor)) {
272
+ checks[name] = projectDeclaredOrCollisionCheck(name, sensor, recovered.packageRoot, packSelected, liveIds);
273
+ continue;
274
+ }
275
+ const packSensor = sensor;
276
+ checks[name] = packSensor.enabled === false
277
+ ? { ok: true, detail: 'disabled' }
278
+ : checkStructuredCommand(packSensor.command, recovered.packageRoot, packSensor.assets);
279
+ }
280
+ for (const entry of recovered.invalidEntries) {
281
+ checks[entry.name] = { ok: false, detail: entry.reason };
282
+ }
283
+ return checks;
284
+ }
200
285
  async function computeSensorStatus(cwd = process.cwd()) {
201
286
  let project;
202
287
  try {
@@ -211,15 +296,40 @@ async function computeSensorStatus(cwd = process.cwd()) {
211
296
  }
212
297
  if (project.state === 'invalid') {
213
298
  let pack = null;
299
+ let reason = 'manifest-malformed';
300
+ let remedy = 'repair-sensor-manifest';
214
301
  try {
215
- const raw = JSON.parse(fs_1.default.readFileSync(project.manifestPath, 'utf-8'));
216
- if (raw && typeof raw === 'object' && !Array.isArray(raw) && !('schemaVersion' in raw) && typeof raw.pack === 'string') {
217
- pack = raw.pack;
302
+ const text = fs_1.default.readFileSync(project.manifestPath, 'utf-8');
303
+ try {
304
+ const raw = JSON.parse(text);
305
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
306
+ const record = raw;
307
+ if (!('schemaVersion' in record) && typeof record.pack === 'string')
308
+ pack = record.pack;
309
+ else if (typeof record.pack === 'string')
310
+ pack = record.pack;
311
+ }
312
+ // JSON.parse succeeded — this is schema invalidity, not "not valid JSON" (RF-4.1).
313
+ if (project.reason.includes('unsupported manifest schemaVersion')) {
314
+ reason = 'schema-unsupported';
315
+ }
316
+ else {
317
+ reason = `schema-invalid: ${project.reason}`;
318
+ // Project-entry schema failures must not lead with `awm sensors init` (RF-3.3).
319
+ remedy = 'repair-the-invalid-sensor-entry-in-sensors-json';
320
+ }
321
+ }
322
+ catch {
323
+ reason = 'manifest-malformed';
218
324
  }
219
325
  }
220
- catch { /* malformed JSON is not configured */ }
221
- const reason = project.reason.includes('unsupported manifest schemaVersion') ? 'schema-unsupported' : 'manifest-malformed';
222
- return invalidStatus(project, pack, reason);
326
+ catch { /* unreadable manifest stays malformed */ }
327
+ // Soft-isolate valid siblings into checks (same WithIssues path as run) while
328
+ // keeping mode=invalid so preflight RF-4.1 still sees schema-invalid authority.
329
+ return {
330
+ ...invalidStatus(project, pack, reason, remedy),
331
+ checks: softIsolatedChecks(project),
332
+ };
223
333
  }
224
334
  const packageRoot = project.packageRoot;
225
335
  const parsed = project.manifest;
@@ -229,38 +339,73 @@ async function computeSensorStatus(cwd = process.cwd()) {
229
339
  ...projectMetadata(project, parsed.pack.mode, `${parsed.pack.mode}-declared`, { declarationReason: parsed.pack.reason }),
230
340
  };
231
341
  }
342
+ if (parsed.kind === 'v3' && parsed.pack.mode === 'project-sensors' && !hasSelectedPack(parsed.pack)) {
343
+ // Only-project: no pack source to resolve. Report each project sensor with
344
+ // project-declared provenance rather than an empty Pack:none DEGRADED (RF-2.8).
345
+ const checks = {};
346
+ for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
347
+ if ((0, manifest_1.isProjectDeclaredSensor)(sensor)) {
348
+ checks[name] = projectSensorCheck(sensor, packageRoot);
349
+ }
350
+ else {
351
+ checks[name] = { ok: false, detail: 'pack-bound sensor requires a nonempty pack' };
352
+ }
353
+ }
354
+ return {
355
+ overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
356
+ pack: null,
357
+ checks,
358
+ ...projectMetadata(project, 'project-sensors', 'project-declared-only'),
359
+ };
360
+ }
232
361
  if (parsed.kind === 'v2' || parsed.kind === 'v3') {
233
362
  const parsedPack = parsed.pack;
363
+ const packId = typeof parsedPack.pack === 'string' ? parsedPack.pack : null;
364
+ const packSelected = typeof parsedPack.pack === 'string' && parsedPack.pack.length > 0;
234
365
  let resolution;
235
366
  try {
236
367
  resolution = (0, source_1.resolveSensorSource)(parsed, { registries: (0, registries_1.listRegistries)() });
237
368
  }
238
369
  catch {
239
- return invalidStatus(project, parsedPack.pack, 'sensor-source-invalid', 'repair-or-run-awm-update');
370
+ return invalidStatus(project, packId, 'sensor-source-invalid', 'repair-or-run-awm-update');
240
371
  }
241
372
  if (!('source' in resolution))
242
- return sourceFailure(project, parsedPack.pack, resolution);
373
+ return sourceFailure(project, packId ?? 'unknown', resolution);
243
374
  const sourceMeta = resolvedSourceMetadata(resolution);
244
- const manifest = parsedPack;
375
+ const liveIds = livePackSensorIds(resolution.source);
376
+ const packManifest = packBoundOnly(parsedPack);
245
377
  const checks = {};
246
378
  let compatibility;
247
379
  try {
248
- compatibility = resolveStaticV2Compatibility(packageRoot, manifest, resolution.source);
380
+ compatibility = resolveStaticV2Compatibility(packageRoot, packManifest, resolution.source);
249
381
  }
250
382
  catch (error) {
251
383
  const detail = error instanceof Error ? error.message : 'live compatibility unavailable';
252
- for (const [name, sensor] of Object.entries(manifest.sensors))
253
- checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' } : { ok: false, detail };
254
- return { overall: 'DEGRADED', pack: manifest.pack, checks, ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }) };
384
+ for (const [name, sensor] of Object.entries(parsedPack.sensors)) {
385
+ if ((0, manifest_1.isProjectDeclaredSensor)(sensor)) {
386
+ checks[name] = projectDeclaredOrCollisionCheck(name, sensor, packageRoot, packSelected, liveIds);
387
+ }
388
+ else {
389
+ checks[name] = sensor.enabled === false
390
+ ? { ok: true, detail: 'disabled' }
391
+ : { ok: false, detail };
392
+ }
393
+ }
394
+ return { overall: 'DEGRADED', pack: packId, checks, ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }) };
255
395
  }
256
- for (const [name, sensor] of Object.entries(manifest.sensors)) {
257
- checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' }
258
- : staticCompatibilityCheck(sensor, compatibility[name]) ?? checkStructuredCommand(sensor.command, packageRoot, sensor.assets);
396
+ for (const [name, sensor] of Object.entries(parsedPack.sensors)) {
397
+ if ((0, manifest_1.isProjectDeclaredSensor)(sensor)) {
398
+ checks[name] = projectDeclaredOrCollisionCheck(name, sensor, packageRoot, packSelected, liveIds);
399
+ continue;
400
+ }
401
+ const packSensor = sensor;
402
+ checks[name] = packSensor.enabled === false ? { ok: true, detail: 'disabled' }
403
+ : staticCompatibilityCheck(packSensor, compatibility[name]) ?? checkStructuredCommand(packSensor.command, packageRoot, packSensor.assets);
259
404
  }
260
- const uninitialized = uninitializedSensors(manifest, compatibility);
405
+ const uninitialized = uninitializedSensors(packManifest, compatibility);
261
406
  return {
262
407
  overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
263
- pack: manifest.pack, checks,
408
+ pack: packId, checks,
264
409
  ...(uninitialized ? { uninitialized } : {}),
265
410
  ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }),
266
411
  };
@@ -75,7 +75,11 @@ exports.defaultActions = {
75
75
  throw new Error('sensor init does not migrate an existing v2 manifest; run awm sensors bootstrap');
76
76
  if (plan.kind === 'create')
77
77
  (0, bootstrap_1.applySensorBootstrap)(plan);
78
- const pack = plan.kind === 'create' && plan.manifest.mode === 'project-sensors' ? plan.manifest.pack : (0, detection_1.detectStack)(o.cwd).pack;
78
+ const detected = (0, detection_1.detectStack)(o.cwd).pack;
79
+ const declared = plan.kind === 'create' && plan.manifest.mode === 'project-sensors' ? plan.manifest.pack : undefined;
80
+ const pack = typeof declared === 'string' && declared.length > 0 ? declared : detected;
81
+ if (typeof pack !== 'string' || pack.length === 0)
82
+ throw new Error('sensor init could not resolve a pack id');
79
83
  return { detection: { pack }, manifest: { pack } };
80
84
  },
81
85
  addExtension: (root, name) => { (0, profile_1.addExtension)(root, name); },
@@ -397,6 +397,19 @@ describe('preflight', () => {
397
397
  expect(execution.remedy).toContain('awm sensors init');
398
398
  expect(execution.remedy).not.toContain('named sensor');
399
399
  });
400
+ it('does not treat overall=pass as unconditional green when invalidEntries are present', async () => {
401
+ const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
402
+ mockRunSensors.mockResolvedValue({
403
+ overall: 'pass',
404
+ sensors: [{ name: 'good', status: 'pass', errors: [] }],
405
+ invalidEntries: [{ name: 'broken', reason: 'sensors.broken.formatter is not a registered formatter id' }],
406
+ });
407
+ const report = await (0, checks_1.preflight)(dir, { verifySensors: true });
408
+ const execution = check(report, 'sensors-execution');
409
+ expect(execution.ok).toBe(false);
410
+ expect(execution.detail).toMatch(/invalidEntries|broken/i);
411
+ expect(execution.detail).not.toBe('all selected sensors completed with pass');
412
+ });
400
413
  it('reports not_configured when no sensor manifest exists', async () => {
401
414
  // The team-rollout case: a developer clones the repo and never runs
402
415
  // `awm sensors init`. Today nothing notices until an unattended run is already
@@ -813,4 +826,112 @@ describe('preflight', () => {
813
826
  expect(check(report, 'manifest').detail).not.toContain('no .awm/sensors.json');
814
827
  expect(check(report, 'tools')).toBeUndefined();
815
828
  });
829
+ describe('project-declared diagnostics (S3)', () => {
830
+ it('does not claim not-valid-JSON when JSON parses but a project field fails schema (RF-4.1/4.2)', async () => {
831
+ const dir = make({
832
+ manifest: {
833
+ schemaVersion: 3,
834
+ mode: 'project-sensors',
835
+ sensors: {
836
+ broken: {
837
+ source: 'project',
838
+ enabled: true,
839
+ command: { executable: 'terraform', resolution: 'path', args: ['fmt', '-check'] },
840
+ formatter: 'not-a-formatter',
841
+ },
842
+ },
843
+ },
844
+ });
845
+ const report = await (0, checks_1.preflight)(dir);
846
+ const manifestCheck = check(report, 'manifest');
847
+ expect(manifestCheck.ok).toBe(false);
848
+ expect(manifestCheck.detail).not.toBe('.awm/sensors.json is not valid JSON');
849
+ expect(manifestCheck.detail).not.toMatch(/not valid JSON/i);
850
+ expect(manifestCheck.detail).toMatch(/broken/);
851
+ expect(manifestCheck.detail).toMatch(/formatter/);
852
+ expect(report.reason).toMatch(/schema-invalid/i);
853
+ });
854
+ it('still reports not-valid-JSON for truly broken JSON (RF-4.1)', async () => {
855
+ const dir = make({ manifest: '{ not json' });
856
+ const report = await (0, checks_1.preflight)(dir);
857
+ expect(check(report, 'manifest').detail).toContain('not valid JSON');
858
+ });
859
+ it('does not recommend awm sensors init as the first remedy for project schema invalidity (RF-3.3)', async () => {
860
+ const dir = make({
861
+ manifest: {
862
+ schemaVersion: 3,
863
+ mode: 'project-sensors',
864
+ sensors: {
865
+ broken: {
866
+ source: 'project',
867
+ enabled: true,
868
+ command: { executable: 'terraform', resolution: 'path', args: ['fmt'] },
869
+ formatter: 'not-a-formatter',
870
+ },
871
+ },
872
+ },
873
+ });
874
+ const remedy = check(await (0, checks_1.preflight)(dir), 'manifest').remedy ?? '';
875
+ const firstLine = remedy.split('\n')[0] ?? remedy;
876
+ expect(firstLine).not.toMatch(/awm sensors init/i);
877
+ // RF-3.3 positive: first remedy names repair of the entry/field, not regen.
878
+ expect(firstLine).toMatch(/repair|entry|field/i);
879
+ });
880
+ it('uses an only-project-honest tools remedy when sensors are empty and pack is null', async () => {
881
+ const dir = make({
882
+ manifest: {
883
+ schemaVersion: 3,
884
+ mode: 'project-sensors',
885
+ sensors: {},
886
+ },
887
+ });
888
+ const report = await (0, checks_1.preflight)(dir);
889
+ const tools = check(report, 'tools');
890
+ expect(tools.ok).toBe(false);
891
+ expect(tools.remedy ?? '').not.toMatch(/['"]null['"]/);
892
+ expect(tools.remedy ?? '').not.toMatch(/awm update/i);
893
+ expect(tools.remedy ?? '').not.toMatch(/registry has no pack/i);
894
+ expect(tools.remedy ?? '').toMatch(/source:"project"|declare a pack/i);
895
+ });
896
+ it('accepts only-project READY status without inventing a pack id (RF-2.8)', async () => {
897
+ const pathDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-only-project-path-'));
898
+ const previousPath = process.env.PATH;
899
+ try {
900
+ fs_1.default.writeFileSync(path_1.default.join(pathDir, 'terraform'), '#!/bin/sh\nexit 0\n');
901
+ fs_1.default.chmodSync(path_1.default.join(pathDir, 'terraform'), 0o755);
902
+ process.env.PATH = pathDir;
903
+ const dir = make({
904
+ manifest: {
905
+ schemaVersion: 3,
906
+ mode: 'project-sensors',
907
+ sensors: {
908
+ 'iac-format': {
909
+ source: 'project',
910
+ enabled: true,
911
+ command: { executable: 'terraform', resolution: 'path', args: ['fmt', '-check'] },
912
+ formatter: 'exit-code',
913
+ },
914
+ },
915
+ },
916
+ });
917
+ const report = await (0, checks_1.preflight)(dir);
918
+ const manifestCheck = check(report, 'manifest');
919
+ expect(manifestCheck.ok).toBe(true);
920
+ expect(manifestCheck.detail).not.toMatch(/sensor authority is unavailable/i);
921
+ expect(manifestCheck.detail).not.toMatch(/pack ['"]?generic['"]?/i);
922
+ // only-project READY must say so — loose "sensors enabled" alone is pack wording.
923
+ expect(manifestCheck.detail).toMatch(/only-project/i);
924
+ expect(manifestCheck.detail).toMatch(/project-declared/i);
925
+ expect(check(report, 'tools').ok).toBe(true);
926
+ expect(report.mode).toBe('project-sensors');
927
+ }
928
+ finally {
929
+ if (previousPath === undefined)
930
+ delete process.env.PATH;
931
+ else
932
+ process.env.PATH = previousPath;
933
+ fs_1.default.rmSync(pathDir, { recursive: true, force: true });
934
+ }
935
+ });
936
+ });
816
937
  });
@@ -230,3 +230,13 @@ describe('planSensorBootstrap', () => {
230
230
  }
231
231
  });
232
232
  });
233
+ describe('bootstrap only-project honesty (S3)', () => {
234
+ it('does not invent pack:"generic" when planning an only-project write path is requested via pack null (RF-3.2)', async () => {
235
+ // Bootstrap options today reject null pack (stable id only). Until an explicit
236
+ // pack-less planner mode exists, requesting pack:null must fail closed — never
237
+ // coerce to generic and write a lying pack pin.
238
+ project_1.resolveSensorProject.mockReturnValue(missing);
239
+ await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors', pack: null })).rejects.toThrow(/pack/i);
240
+ expect(detection_1.detectStack).not.toHaveBeenCalled();
241
+ });
242
+ });
@@ -19,6 +19,26 @@ function validV2Manifest() {
19
19
  },
20
20
  };
21
21
  }
22
+ function projectSensor(overrides = {}) {
23
+ return {
24
+ source: 'project',
25
+ enabled: true,
26
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
27
+ ...overrides,
28
+ };
29
+ }
30
+ function v3WithPackAndProject(projectOverrides = {}) {
31
+ return {
32
+ schemaVersion: 3,
33
+ mode: 'project-sensors',
34
+ pack: 'js-ts',
35
+ source: { registry: 'baseline' },
36
+ sensors: {
37
+ lint: validV2Manifest().sensors.lint,
38
+ custom: projectSensor(projectOverrides),
39
+ },
40
+ };
41
+ }
22
42
  describe('sensor manifest contract', () => {
23
43
  it('keeps compatibility contracts on an acyclic import boundary', () => {
24
44
  const source = (relative) => fs_1.default.readFileSync(path_1.default.join(__dirname, '../../../../src/commands/sensors', relative), 'utf8');
@@ -192,4 +212,188 @@ describe('sensor manifest contract', () => {
192
212
  ])('rejects malformed manifest %j', (input, message) => {
193
213
  expect(() => (0, manifest_1.parseSensorManifest)(input, 'sensors.json')).toThrow(message);
194
214
  });
215
+ describe('project-declared sensors (S1)', () => {
216
+ it('accepts a source:project entry alongside a pack-bound lint sensor', () => {
217
+ const manifest = v3WithPackAndProject();
218
+ const parsed = (0, manifest_1.parseSensorManifest)(manifest, 'sensors.json');
219
+ expect(parsed).toMatchObject({
220
+ kind: 'v3',
221
+ pack: {
222
+ mode: 'project-sensors',
223
+ pack: 'js-ts',
224
+ sensors: {
225
+ lint: { variantId: 'eslint-9', enabled: true },
226
+ custom: {
227
+ source: 'project',
228
+ enabled: true,
229
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
230
+ formatter: 'exit-code',
231
+ },
232
+ },
233
+ },
234
+ });
235
+ expect(parsed.kind === 'v3' && parsed.pack.mode === 'project-sensors' && 'variantId' in parsed.pack.sensors.custom).toBe(false);
236
+ expect(parsed.kind === 'v3' && parsed.pack.mode === 'project-sensors' && 'initializedCompatibility' in parsed.pack.sensors.custom).toBe(false);
237
+ });
238
+ it('accepts an only-project manifest with pack omitted or null', () => {
239
+ const base = {
240
+ schemaVersion: 3,
241
+ mode: 'project-sensors',
242
+ sensors: { custom: projectSensor() },
243
+ };
244
+ for (const manifest of [base, { ...base, pack: null }]) {
245
+ const parsed = (0, manifest_1.parseSensorManifest)(manifest, 'sensors.json');
246
+ expect(parsed).toMatchObject({
247
+ kind: 'v3',
248
+ pack: {
249
+ mode: 'project-sensors',
250
+ sensors: { custom: { source: 'project', formatter: 'exit-code' } },
251
+ },
252
+ });
253
+ if (parsed.kind === 'v3' && parsed.pack.mode === 'project-sensors') {
254
+ expect(parsed.pack.pack === undefined || parsed.pack.pack === null).toBe(true);
255
+ expect(parsed.pack.source).toBeUndefined();
256
+ }
257
+ }
258
+ });
259
+ it('accepts an empty only-project sensors map', () => {
260
+ expect((0, manifest_1.parseSensorManifest)({
261
+ schemaVersion: 3,
262
+ mode: 'project-sensors',
263
+ sensors: {},
264
+ }, 'sensors.json')).toMatchObject({
265
+ kind: 'v3',
266
+ pack: { mode: 'project-sensors', sensors: {} },
267
+ });
268
+ });
269
+ it.each(['variantId', 'initializedCompatibility'])('rejects a project entry that declares forbidden pack field %s', field => {
270
+ const value = field === 'variantId'
271
+ ? 'eslint-9'
272
+ : { state: 'certified', reason: 'ok', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [] };
273
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ [field]: value }), 'sensors.json');
274
+ expect(soft.invalidEntries).toEqual([
275
+ expect.objectContaining({
276
+ name: 'custom',
277
+ reason: expect.stringMatching(new RegExp(`sensors\\.custom\\.${field}`)),
278
+ }),
279
+ ]);
280
+ expect(soft).toMatchObject({
281
+ kind: 'v3',
282
+ pack: { sensors: { lint: { variantId: 'eslint-9' } } },
283
+ });
284
+ expect('custom' in soft.pack.sensors).toBe(false);
285
+ });
286
+ it('soft-rejects an invalid project command with sensors.<name>.command in the reason (RF-1.12)', () => {
287
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({
288
+ command: { executable: 'bash', resolution: 'path', args: ['-c', 'true'] },
289
+ }), 'sensors.json');
290
+ expect(soft.invalidEntries).toHaveLength(1);
291
+ expect(soft.invalidEntries[0]?.reason).toMatch(/sensors\.custom\.command/);
292
+ expect(soft).toMatchObject({
293
+ kind: 'v3',
294
+ pack: { sensors: { lint: { variantId: 'eslint-9' } } },
295
+ });
296
+ expect('custom' in soft.pack.sensors).toBe(false);
297
+ });
298
+ it('soft-rejects an unmarked custom command as missing source (RF-1.2)', () => {
299
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)({
300
+ schemaVersion: 3,
301
+ mode: 'project-sensors',
302
+ pack: 'js-ts',
303
+ source: { registry: 'baseline' },
304
+ sensors: {
305
+ lint: validV2Manifest().sensors.lint,
306
+ custom: {
307
+ enabled: true,
308
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
309
+ },
310
+ },
311
+ }, 'sensors.json');
312
+ expect(soft.invalidEntries).toEqual([
313
+ expect.objectContaining({
314
+ name: 'custom',
315
+ reason: expect.stringMatching(/sensors\.custom\.source/),
316
+ }),
317
+ ]);
318
+ expect(soft.invalidEntries[0]?.reason).toMatch(/source/);
319
+ expect(soft).toMatchObject({
320
+ kind: 'v3',
321
+ pack: { sensors: { lint: { variantId: 'eslint-9' } } },
322
+ });
323
+ // Soft-isolation: unmarked custom must be dropped, pack sibling kept.
324
+ expect('custom' in soft.pack.sensors).toBe(false);
325
+ });
326
+ it('rejects unknown formatter and defaults omitted formatter to exit-code', () => {
327
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ formatter: 'not-a-formatter' }), 'sensors.json');
328
+ expect(soft.invalidEntries).toEqual([
329
+ expect.objectContaining({
330
+ name: 'custom',
331
+ reason: expect.stringMatching(/sensors\.custom\.formatter/),
332
+ }),
333
+ ]);
334
+ const ok = (0, manifest_1.parseSensorManifest)(v3WithPackAndProject(), 'sensors.json');
335
+ expect(ok).toMatchObject({
336
+ kind: 'v3',
337
+ pack: { sensors: { custom: { formatter: 'exit-code' } } },
338
+ });
339
+ });
340
+ it('treats omitted applicability as always applicable and rejects invalid shapes', () => {
341
+ const ok = (0, manifest_1.parseSensorManifest)(v3WithPackAndProject(), 'sensors.json');
342
+ expect(ok.kind === 'v3' && ok.pack.mode === 'project-sensors' && !('applicability' in ok.pack.sensors.custom)).toBe(true);
343
+ const withApplicability = (0, manifest_1.parseSensorManifest)(v3WithPackAndProject({ applicability: { allFiles: ['package.json'] } }), 'sensors.json');
344
+ expect(withApplicability).toMatchObject({
345
+ kind: 'v3',
346
+ pack: { sensors: { custom: { applicability: { allFiles: ['package.json'] } } } },
347
+ });
348
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ applicability: {} }), 'sensors.json');
349
+ expect(soft.invalidEntries).toEqual([
350
+ expect.objectContaining({
351
+ name: 'custom',
352
+ reason: expect.stringMatching(/sensors\.custom\.applicability/),
353
+ }),
354
+ ]);
355
+ });
356
+ it('rejects schemaVersion 2 and legacy manifests that use source:project with migrate-to-v3', () => {
357
+ const v2 = {
358
+ schemaVersion: 2,
359
+ pack: 'js-ts',
360
+ sensors: {
361
+ custom: {
362
+ source: 'project',
363
+ enabled: true,
364
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
365
+ },
366
+ },
367
+ };
368
+ expect(() => (0, manifest_1.parseSensorManifest)(v2, 'sensors.json')).toThrow(/migrate-to-v3|migrate to v3/i);
369
+ const legacy = {
370
+ pack: 'js-ts',
371
+ sensors: {
372
+ custom: {
373
+ source: 'project',
374
+ enabled: true,
375
+ cmd: 'npm run lint',
376
+ },
377
+ },
378
+ };
379
+ expect(() => (0, manifest_1.parseSensorManifest)(legacy, 'sensors.json')).toThrow(/migrate-to-v3|migrate to v3/i);
380
+ });
381
+ it('soft-isolates one invalid project entry while keeping a valid pack sensor', () => {
382
+ const soft = (0, manifest_1.parseSensorManifestWithIssues)(v3WithPackAndProject({ formatter: 'not-a-formatter' }), 'sensors.json');
383
+ expect(soft.invalidEntries).toHaveLength(1);
384
+ expect(soft.invalidEntries[0]).toMatchObject({
385
+ name: 'custom',
386
+ reason: expect.stringMatching(/sensors\.custom\.formatter/),
387
+ });
388
+ expect(soft).toMatchObject({
389
+ kind: 'v3',
390
+ pack: {
391
+ mode: 'project-sensors',
392
+ sensors: { lint: { enabled: true, variantId: 'eslint-9' } },
393
+ },
394
+ });
395
+ expect('custom' in soft.pack.sensors).toBe(false);
396
+ expect(soft.invalidEntries.some(entry => /not valid JSON|valid JSON/i.test(entry.reason))).toBe(false);
397
+ });
398
+ });
195
399
  });