agentic-workflow-manager 9.11.2 → 9.11.4
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/job/gate.js +19 -5
- package/dist/src/commands/job/index.js +13 -7
- package/dist/src/commands/job/query.js +14 -9
- package/dist/src/commands/preflight/checks.js +9 -2
- package/dist/src/commands/sensors/bootstrap.js +28 -8
- package/dist/src/commands/sensors/index.js +19 -5
- package/dist/src/commands/sensors/init.js +6 -3
- package/dist/src/commands/sensors/status.js +33 -0
- package/dist/src/commands/sensors/unresolved.js +19 -0
- package/dist/src/commands/track/status.js +1 -1
- package/dist/src/commands/watch/supervisor.js +6 -3
- package/dist/src/core/journal/store.js +30 -6
- package/dist/tests/commands/job/absent-vs-corrupt-journal.test.js +134 -0
- package/dist/tests/commands/sensors/bootstrap.test.js +7 -3
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/commands/sensors/partial-pack-bootstrap.test.js +105 -0
- package/dist/tests/commands/sensors/partial-pack-status.test.js +174 -0
- package/dist/tests/commands/track/status.test.js +5 -2
- package/dist/tests/commands/watch/watch-init.test.js +4 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.computeGate = computeGate;
|
|
4
4
|
exports.computeTrackGate = computeTrackGate;
|
|
5
|
+
const store_1 = require("../../core/journal/store");
|
|
5
6
|
const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
|
|
6
7
|
function sameSelection(left, right) {
|
|
7
8
|
return left.selector.kind === right.selector.kind && left.selector.id === right.selector.id && left.effort.kind === right.effort.kind && left.effort.value === right.effort.value;
|
|
@@ -149,9 +150,19 @@ function evaluateEvidence(state, fingerprintNow, scope) {
|
|
|
149
150
|
}
|
|
150
151
|
return { pass: reasons.length === 0, reasons };
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
/**
|
|
154
|
+
* `absent` defaults to false so every existing caller keeps today's behaviour
|
|
155
|
+
* exactly: an unreadable journal fails closed either way. What changes is the
|
|
156
|
+
* REASON an absent one reports — it used to claim corruption, sending operators
|
|
157
|
+
* to look for damage in a healthy repository (#173). The exit code does not
|
|
158
|
+
* change: this interlock has no plan binding to read an execution mode from when
|
|
159
|
+
* the journal is missing, so it stays fail-closed and names the remedy instead.
|
|
160
|
+
*/
|
|
161
|
+
function computeGate(state, corrupt, fingerprintNow, absent = false) {
|
|
153
162
|
if (corrupt || state === null) {
|
|
154
|
-
return
|
|
163
|
+
return absent && !corrupt
|
|
164
|
+
? { pass: false, reasons: [{ category: 'absent', detail: store_1.ABSENT_JOURNAL_DETAIL }] }
|
|
165
|
+
: { pass: false, reasons: [{ category: 'corrupt', detail: 'state.json corrupto o ilegible: la corrupcion jamas certifica' }] };
|
|
155
166
|
}
|
|
156
167
|
return evaluateEvidence(state, fingerprintNow, { requireGlobalKinds: true });
|
|
157
168
|
}
|
|
@@ -161,9 +172,12 @@ function computeGate(state, corrupt, fingerprintNow) {
|
|
|
161
172
|
* ahi) y rechaza cualquier tarea que aparezca en el journal pero no en la
|
|
162
173
|
* asignacion del track (R2.3) — nunca certifica por una tarea ajena que se
|
|
163
174
|
* haya colado. */
|
|
164
|
-
function computeTrackGate(state, corruptState, fingerprintNow) {
|
|
165
|
-
if (corruptState || state === null)
|
|
166
|
-
return
|
|
175
|
+
function computeTrackGate(state, corruptState, fingerprintNow, absent = false) {
|
|
176
|
+
if (corruptState || state === null) {
|
|
177
|
+
return absent && !corruptState
|
|
178
|
+
? { pass: false, reasons: [{ category: 'absent-state', detail: store_1.ABSENT_JOURNAL_DETAIL }] }
|
|
179
|
+
: { pass: false, reasons: [{ category: 'corrupt-state', detail: 'journal corrupto o ilegible' }] };
|
|
180
|
+
}
|
|
167
181
|
if (state.trackContext === undefined)
|
|
168
182
|
return { pass: false, reasons: [{ category: 'wrong-context', detail: 'gate local requiere trackContext' }] };
|
|
169
183
|
const assigned = new Set(state.trackContext.taskIds);
|
|
@@ -219,8 +219,13 @@ function registerJobCommand(program) {
|
|
|
219
219
|
const branch = branchOf(repo);
|
|
220
220
|
assertAuthenticatedCwd(repo, branch);
|
|
221
221
|
const r = (0, store_1.readJournal)(repo, branch);
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
const presence = (0, store_1.journalPresence)(r);
|
|
223
|
+
if (presence !== 'present' || r.state === null) {
|
|
224
|
+
// Absence and corruption need different responses, so they are
|
|
225
|
+
// reported as different facts. Both still fail closed (#173).
|
|
226
|
+
process.stdout.write(JSON.stringify(presence === 'absent'
|
|
227
|
+
? { journal: 'absent', corruptState: false, remedy: store_1.ABSENT_JOURNAL_DETAIL }
|
|
228
|
+
: { journal: 'corrupt', corruptState: true }, null, 2) + '\n');
|
|
224
229
|
process.exit(1);
|
|
225
230
|
}
|
|
226
231
|
try {
|
|
@@ -257,7 +262,7 @@ function registerJobCommand(program) {
|
|
|
257
262
|
process.exit(1);
|
|
258
263
|
}
|
|
259
264
|
}
|
|
260
|
-
const g = (0, gate_1.computeGate)(r.state, r.corrupt, realFingerprintNow(repo));
|
|
265
|
+
const g = (0, gate_1.computeGate)(r.state, r.corrupt, realFingerprintNow(repo), r.absent);
|
|
261
266
|
process.stdout.write(JSON.stringify(g, null, 2) + '\n');
|
|
262
267
|
if (!g.pass)
|
|
263
268
|
process.exit(1); // falla cerrado (R3.2)
|
|
@@ -270,8 +275,9 @@ function registerJobCommand(program) {
|
|
|
270
275
|
const branch = branchOf(repo);
|
|
271
276
|
assertAuthenticatedCwd(repo, branch);
|
|
272
277
|
const r = (0, store_1.readJournal)(repo, branch);
|
|
273
|
-
|
|
274
|
-
|
|
278
|
+
const presence = (0, store_1.journalPresence)(r);
|
|
279
|
+
if (presence !== 'present' || r.state === null) {
|
|
280
|
+
process.stdout.write(JSON.stringify({ journal: presence, corruptState: presence === 'corrupt' }) + '\n');
|
|
275
281
|
process.exit(1);
|
|
276
282
|
return;
|
|
277
283
|
}
|
|
@@ -287,7 +293,7 @@ function registerJobCommand(program) {
|
|
|
287
293
|
assertAuthenticatedCwd(repo, branch);
|
|
288
294
|
const r = (0, store_1.readJournal)(repo, branch);
|
|
289
295
|
if (r.corrupt || r.state === null) {
|
|
290
|
-
process.stderr.write('journal corrupto o
|
|
296
|
+
process.stderr.write(`${r.absent && !r.corrupt ? store_1.ABSENT_JOURNAL_DETAIL : 'journal corrupto o ilegible'}\n`);
|
|
291
297
|
process.exit(1);
|
|
292
298
|
}
|
|
293
299
|
const plan = (0, reap_1.planReap)(r.state);
|
|
@@ -308,7 +314,7 @@ function registerJobCommand(program) {
|
|
|
308
314
|
assertAuthenticatedCwd(repo, branch);
|
|
309
315
|
const r = (0, store_1.readJournal)(repo, branch);
|
|
310
316
|
if (r.corrupt || r.state === null) {
|
|
311
|
-
process.stderr.write('journal corrupto\n
|
|
317
|
+
process.stderr.write(`${r.absent && !r.corrupt ? store_1.ABSENT_JOURNAL_DETAIL : 'journal corrupto o ilegible'}\n`);
|
|
312
318
|
process.exit(1);
|
|
313
319
|
}
|
|
314
320
|
let baseline = null;
|
|
@@ -6,23 +6,27 @@ exports.queryShow = queryShow;
|
|
|
6
6
|
const store_1 = require("../../core/journal/store");
|
|
7
7
|
const process_1 = require("../../core/journal/process");
|
|
8
8
|
/** Fuente unica de "que hay corriendo": cruza identidad completa contra
|
|
9
|
-
* procesos vivos. corrupt es VISIBLE, nunca descartado (R1.6)
|
|
9
|
+
* procesos vivos. corrupt es VISIBLE, nunca descartado (R1.6), y un journal
|
|
10
|
+
* que nunca existio se reporta como `absent`, no como corrupcion (#173). */
|
|
10
11
|
function queryPs(repoRoot, branch) {
|
|
11
12
|
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const journal = (0, store_1.journalPresence)(r);
|
|
14
|
+
if (journal !== 'present')
|
|
15
|
+
return { corruptState: journal === 'corrupt', journal, jobs: [] };
|
|
14
16
|
const jobs = Object.values(r.state.jobs).map((j) => ({
|
|
15
17
|
id: j.id, executionState: j.executionState, observationState: j.observationState, verdict: j.verdict,
|
|
16
18
|
alive: j.processRef ? (0, process_1.refIsAlive)(j.processRef) : 'sin-pid',
|
|
17
19
|
}));
|
|
18
|
-
return { corruptState: false, jobs };
|
|
20
|
+
return { corruptState: false, journal, jobs };
|
|
19
21
|
}
|
|
20
22
|
function queryList(repoRoot, branch) {
|
|
21
23
|
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
const journal = (0, store_1.journalPresence)(r);
|
|
25
|
+
if (journal !== 'present')
|
|
26
|
+
return { corruptState: journal === 'corrupt', journal, cycleStatus: null, jobs: [] };
|
|
24
27
|
return {
|
|
25
28
|
corruptState: false,
|
|
29
|
+
journal,
|
|
26
30
|
cycleStatus: r.state.cycle.status,
|
|
27
31
|
jobs: Object.values(r.state.jobs).map((j) => ({
|
|
28
32
|
id: j.id, executionState: j.executionState, verdict: j.verdict, argv: j.argv, satisfies: j.satisfies,
|
|
@@ -31,7 +35,8 @@ function queryList(repoRoot, branch) {
|
|
|
31
35
|
}
|
|
32
36
|
function queryShow(repoRoot, branch, jobId) {
|
|
33
37
|
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const journal = (0, store_1.journalPresence)(r);
|
|
39
|
+
if (journal !== 'present')
|
|
40
|
+
return { corruptState: journal === 'corrupt', journal, job: null };
|
|
41
|
+
return { corruptState: false, journal, job: r.state.jobs[jobId] ?? null };
|
|
37
42
|
}
|
|
@@ -155,11 +155,18 @@ function checkManifest(status) {
|
|
|
155
155
|
remedy: `registry has no pack for '${manifest.pack}': run \`awm update\` or add a registry that has it`,
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
|
+
// An applicable sensor the project cannot initialize is a real gap, so it is
|
|
159
|
+
// named here rather than left as an absence. It does not flip `ok`: whether an
|
|
160
|
+
// uninitialized sensor should block a handoff is a gate-policy decision, and
|
|
161
|
+
// flipping it would restore the dead end #172 is about — a project whose only
|
|
162
|
+
// gap is one missing tool could then configure sensors and still never pass.
|
|
163
|
+
const uninitialized = Object.keys(status.uninitialized ?? {});
|
|
164
|
+
const gap = uninitialized.length === 0 ? '' : `; ${uninitialized.length} applicable sensor(s) not initialized: ${uninitialized.join(', ')}`;
|
|
158
165
|
return {
|
|
159
166
|
id: 'manifest',
|
|
160
167
|
ok: true,
|
|
161
|
-
detail: enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
|
|
162
|
-
: `pack ${manifest.pack}, ${enabled}/${total} sensors enabled
|
|
168
|
+
detail: (enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
|
|
169
|
+
: `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`) + gap,
|
|
163
170
|
};
|
|
164
171
|
}
|
|
165
172
|
/** Every enabled sensor's command must resolve. This is the check nothing was calling. */
|
|
@@ -20,8 +20,8 @@ const safe_file_1 = require("./compatibility/safe-file");
|
|
|
20
20
|
const fs_1 = __importDefault(require("fs"));
|
|
21
21
|
const crypto_1 = require("crypto");
|
|
22
22
|
const path_1 = __importDefault(require("path"));
|
|
23
|
-
function blocked(projectRoot, manifestPath, dryRun, reason, remedy, candidates) {
|
|
24
|
-
return { kind: 'blocked', projectRoot, manifestPath, changes: [], dryRun, reason, remedy, ...(candidates ? { candidates } : {}) };
|
|
23
|
+
function blocked(projectRoot, manifestPath, dryRun, reason, remedy, candidates, unresolved) {
|
|
24
|
+
return { kind: 'blocked', projectRoot, manifestPath, changes: [], dryRun, reason, remedy, ...(candidates ? { candidates } : {}), ...(unresolved && unresolved.length > 0 ? { unresolved } : {}) };
|
|
25
25
|
}
|
|
26
26
|
function options(input) {
|
|
27
27
|
if (input === undefined)
|
|
@@ -84,21 +84,40 @@ async function projectSensors(projectRoot, registryRoot, requestedPack, packageR
|
|
|
84
84
|
return { reason: 'compatibility-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
|
|
85
85
|
}
|
|
86
86
|
const sensors = {};
|
|
87
|
+
const unresolved = [];
|
|
88
|
+
let applicable = 0;
|
|
87
89
|
for (const [name, packSensor] of Object.entries(live.pack.sensors)) {
|
|
88
90
|
const evidence = live.sensors[name];
|
|
89
91
|
if (evidence.state === 'not-applicable')
|
|
90
92
|
continue;
|
|
93
|
+
applicable += 1;
|
|
91
94
|
const variant = evidence.variantId === null ? undefined : packSensor.variants.find(item => item.id === evidence.variantId);
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
// One applicable sensor whose tool is absent used to abort the whole
|
|
96
|
+
// manifest, including the sensors that DID resolve, so a project whose
|
|
97
|
+
// only gap was (say) semgrep could not configure sensors at all — and so
|
|
98
|
+
// could not pass preflight, and so could not be handed off. A missing tool
|
|
99
|
+
// is a gap in one sensor, not grounds to refuse the pack. It is skipped
|
|
100
|
+
// and REPORTED by name; nothing is invented for it, because the v3 entry
|
|
101
|
+
// shape requires a real variant, command and probe evidence. See #172.
|
|
102
|
+
if (!variant) {
|
|
103
|
+
unresolved.push({ sensor: name, state: evidence.state, reason: evidence.reason });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
94
106
|
sensors[name] = { enabled: true, ...(packSensor.fast === undefined ? {} : { fast: packSensor.fast }), ...(packSensor.timeout === undefined ? {} : { timeout: packSensor.timeout }), variantId: variant.id, command: variant.command, ...(variant.assets.length === 0 ? {} : { assets: variant.assets }), ...(variant.policyRef ? { policyRef: variant.policyRef } : {}), initializedCompatibility: evidence };
|
|
95
107
|
}
|
|
108
|
+
// A pack with NO applicable sensor still writes an honest empty manifest: that
|
|
109
|
+
// is the distinct "the registry has no pack for this stack" signal preflight
|
|
110
|
+
// already reports. But a pack where sensors apply and none of them resolve is
|
|
111
|
+
// a different condition, and writing an empty manifest would collide with it.
|
|
112
|
+
if (applicable > 0 && Object.keys(sensors).length === 0) {
|
|
113
|
+
return { reason: 'no-sensor-resolvable', remedy: 'repair-project-tools-or-select-another-mode', unresolved };
|
|
114
|
+
}
|
|
96
115
|
const manifest = (0, manifest_1.parseSensorManifest)({ schemaVersion: 3, mode: 'project-sensors', pack, source: { registry: source.registry.name }, ...(packageRoot ? { packageRoot } : {}), sensors }, 'bootstrap candidate');
|
|
97
116
|
if (manifest.kind !== 'v3' || manifest.pack.mode !== 'project-sensors')
|
|
98
117
|
throw new Error('bootstrap project-sensors candidate is invalid');
|
|
99
118
|
(0, manifest_1.serializeManifestV3)(manifest.pack);
|
|
100
119
|
const assets = [...new Set(Object.values(sensors).flatMap(sensor => sensor.assets ?? []))].sort();
|
|
101
|
-
return { manifest: manifest.pack, source, changes: [{ path: '.awm/sensors.json', action: 'create' }, ...assets.map(asset => ({ path: packageRoot ? `${packageRoot}/${asset}` : asset, action: 'create' }))] };
|
|
120
|
+
return { manifest: manifest.pack, source, changes: [{ path: '.awm/sensors.json', action: 'create' }, ...assets.map(asset => ({ path: packageRoot ? `${packageRoot}/${asset}` : asset, action: 'create' }))], unresolved };
|
|
102
121
|
}
|
|
103
122
|
/** Plan a one-time portable bootstrap. This function never writes project or machine state. */
|
|
104
123
|
async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
|
|
@@ -125,7 +144,7 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
|
|
|
125
144
|
// activate v2 sensors which require an explicit project opt-in.
|
|
126
145
|
const planned = await projectSensors(project.projectRoot, opts.registryRoot, project.manifest.pack.pack, undefined, false);
|
|
127
146
|
if ('reason' in planned)
|
|
128
|
-
return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
|
|
147
|
+
return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined, planned.unresolved);
|
|
129
148
|
try {
|
|
130
149
|
const original = fs_1.default.readFileSync(project.manifestPath);
|
|
131
150
|
return {
|
|
@@ -133,6 +152,7 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
|
|
|
133
152
|
changes: planned.changes.map((change, index) => index === 0 ? { ...change, action: 'replace' } : change),
|
|
134
153
|
dryRun: opts.dryRun, migration: { kind: 'legacy-v1', manifest: planned.manifest }, source: planned.source,
|
|
135
154
|
originalDigest: digest(original),
|
|
155
|
+
...(planned.unresolved.length > 0 ? { unresolved: planned.unresolved } : {}),
|
|
136
156
|
};
|
|
137
157
|
}
|
|
138
158
|
catch {
|
|
@@ -165,8 +185,8 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
|
|
|
165
185
|
}
|
|
166
186
|
const planned = await projectSensors(project.projectRoot, opts.registryRoot, opts.pack, opts.packageRoot);
|
|
167
187
|
if ('reason' in planned)
|
|
168
|
-
return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
|
|
169
|
-
return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: planned.changes, dryRun: opts.dryRun, manifest: planned.manifest, source: planned.source, configure: opts.configure };
|
|
188
|
+
return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined, planned.unresolved);
|
|
189
|
+
return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: planned.changes, dryRun: opts.dryRun, manifest: planned.manifest, source: planned.source, configure: opts.configure, ...(planned.unresolved.length > 0 ? { unresolved: planned.unresolved } : {}) };
|
|
170
190
|
}
|
|
171
191
|
/** Apply only a validated, non-dry-run create or migration plan. */
|
|
172
192
|
function applySensorBootstrap(plan) {
|
|
@@ -16,6 +16,7 @@ const render_1 = require("./coverage/render");
|
|
|
16
16
|
const registries_1 = require("../../core/registries");
|
|
17
17
|
const verdict_1 = require("./verdict");
|
|
18
18
|
const bootstrap_1 = require("./bootstrap");
|
|
19
|
+
const unresolved_1 = require("./unresolved");
|
|
19
20
|
/** Commander coercion for coverage recurrence emphasis. It deliberately runs
|
|
20
21
|
* before the action, so an invalid value cannot trigger ledger I/O. */
|
|
21
22
|
function parsePositiveSafeInteger(value) {
|
|
@@ -72,8 +73,10 @@ function registerSensorsCommand(program) {
|
|
|
72
73
|
const registryRoot = opts.registryRoot ?? (0, registries_1.capabilityRoot)('sensor-packs') ?? undefined;
|
|
73
74
|
try {
|
|
74
75
|
const plan = await (0, bootstrap_1.planSensorBootstrap)(process.cwd(), { mode: 'project-sensors', registryRoot, configure: opts.configure, pack: opts.pack, packageRoot: opts.packageRoot });
|
|
75
|
-
if (plan.kind === 'blocked')
|
|
76
|
-
|
|
76
|
+
if (plan.kind === 'blocked') {
|
|
77
|
+
const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
|
|
78
|
+
throw new Error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
|
|
79
|
+
}
|
|
77
80
|
if (plan.kind === 'noop') {
|
|
78
81
|
prompts_1.log.info('already-configured');
|
|
79
82
|
return;
|
|
@@ -83,8 +86,12 @@ function registerSensorsCommand(program) {
|
|
|
83
86
|
const result = (0, bootstrap_1.applySensorBootstrap)(plan);
|
|
84
87
|
const detected = plan.manifest.mode === 'project-sensors' ? plan.manifest.pack : 'none';
|
|
85
88
|
prompts_1.log.success(`Detected: ${detected}`);
|
|
86
|
-
// Said BEFORE "Wrote .awm/sensors.json":
|
|
87
|
-
// reported as
|
|
89
|
+
// Said BEFORE "Wrote .awm/sensors.json": a partially resolved pack
|
|
90
|
+
// must not be reported as a clean write, and the manifest about to
|
|
91
|
+
// be reported as written is not the one the detection implied.
|
|
92
|
+
const skipped = (0, unresolved_1.describeUnresolved)(plan.unresolved);
|
|
93
|
+
if (skipped !== '')
|
|
94
|
+
prompts_1.log.warn(`not initialized (tool unavailable here): ${skipped}`);
|
|
88
95
|
prompts_1.log.success(`${result} .awm/sensors.json`);
|
|
89
96
|
}
|
|
90
97
|
catch (e) {
|
|
@@ -102,7 +109,8 @@ function registerSensorsCommand(program) {
|
|
|
102
109
|
try {
|
|
103
110
|
const plan = await (0, bootstrap_1.planSensorBootstrap)(process.cwd(), { mode: opts.mode, reason: opts.reason, dryRun: opts.dryRun });
|
|
104
111
|
if (plan.kind === 'blocked') {
|
|
105
|
-
|
|
112
|
+
const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
|
|
113
|
+
prompts_1.log.error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
|
|
106
114
|
process.exitCode = 1;
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
@@ -154,6 +162,12 @@ function registerSensorsCommand(program) {
|
|
|
154
162
|
const mark = check.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘');
|
|
155
163
|
console.log(` ${mark} ${name.padEnd(12)} ${check.detail}`);
|
|
156
164
|
}
|
|
165
|
+
// A pack sensor that applies here but has no manifest entry is otherwise
|
|
166
|
+
// indistinguishable from one the pack never applied. It does not change
|
|
167
|
+
// the verdict; it is named so the gap is attributable. See #172.
|
|
168
|
+
for (const [name, gap] of Object.entries(status.uninitialized ?? {})) {
|
|
169
|
+
console.log(` ${picocolors_1.default.yellow('–')} ${name.padEnd(12)} not initialized: ${gap.state}/${gap.reason}`);
|
|
170
|
+
}
|
|
157
171
|
console.log('');
|
|
158
172
|
if (status.overall !== 'READY')
|
|
159
173
|
process.exit(1);
|
|
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const pack_source_1 = require("./compatibility/pack-source");
|
|
13
13
|
const bootstrap_1 = require("./bootstrap");
|
|
14
|
+
const unresolved_1 = require("./unresolved");
|
|
14
15
|
/** Read a pack only through the shared source resolver. Init is a registry
|
|
15
16
|
* boundary: a pack path that is absent is harmless, but a symlink or escaping
|
|
16
17
|
* claimed source is never silently downgraded to an empty manifest. */
|
|
@@ -155,8 +156,10 @@ async function initSensors(opts = {}) {
|
|
|
155
156
|
mode: 'project-sensors', registryRoot: opts.registryRoot, configure: opts.configure,
|
|
156
157
|
pack: opts.pack, packageRoot: opts.packageRoot,
|
|
157
158
|
});
|
|
158
|
-
if (plan.kind === 'blocked')
|
|
159
|
-
|
|
159
|
+
if (plan.kind === 'blocked') {
|
|
160
|
+
const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
|
|
161
|
+
throw new Error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
|
|
162
|
+
}
|
|
160
163
|
if (plan.kind === 'migrate')
|
|
161
164
|
throw new Error('initSensors does not migrate existing v2 manifests; run awm sensors bootstrap');
|
|
162
165
|
if (plan.kind === 'noop')
|
|
@@ -166,5 +169,5 @@ async function initSensors(opts = {}) {
|
|
|
166
169
|
manifest: {}, detection: detectStack(detectionCwd), configured: [], status: 'already-configured',
|
|
167
170
|
};
|
|
168
171
|
(0, bootstrap_1.applySensorBootstrap)(plan);
|
|
169
|
-
return { manifest: plan.manifest, detection: detectStack(detectionCwd), configured: [], status: 'created' };
|
|
172
|
+
return { manifest: plan.manifest, detection: detectStack(detectionCwd), configured: [], status: 'created', ...(plan.unresolved && plan.unresolved.length > 0 ? { unresolved: plan.unresolved } : {}) };
|
|
170
173
|
}
|
|
@@ -124,9 +124,40 @@ function resolveStaticV2Compatibility(cwd, manifest, source) {
|
|
|
124
124
|
: (0, resolve_1.resolveSensorCompatibility)(sensor, { ...resolutionEvidence, probe: { status: probeStatus } }, { pack: executionPack.name, sensor: name })];
|
|
125
125
|
}));
|
|
126
126
|
}
|
|
127
|
+
/** Applicable pack sensors with no manifest entry, so the gap is attributable in
|
|
128
|
+
* every `awm sensors status`, not only in the output of the `init` that skipped it. */
|
|
129
|
+
function uninitializedSensors(manifest, compatibility) {
|
|
130
|
+
const entries = Object.entries(compatibility)
|
|
131
|
+
.filter(([name, evidence]) => evidence !== undefined && evidence.state !== 'not-applicable' && !Object.prototype.hasOwnProperty.call(manifest.sensors, name))
|
|
132
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
133
|
+
.map(([name, evidence]) => [name, { state: evidence.state, reason: evidence.reason }]);
|
|
134
|
+
return entries.length === 0 ? undefined : Object.fromEntries(entries);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Status never executes, so `discoverProjectEvidence` is called here without
|
|
138
|
+
* `pathToolVersion` and a `resolution: 'path'` tool is structurally invisible to
|
|
139
|
+
* it: `npm` on PATH resolves as `missing-tool`/`tool-not-found` whether it is
|
|
140
|
+
* installed or not. Reporting that as "compatibility drift" asserts something
|
|
141
|
+
* this command did not measure. `checkStructuredCommand` DOES consult PATH for
|
|
142
|
+
* exactly these commands, so defer to it rather than claim drift. Any other live
|
|
143
|
+
* state, or a genuinely different resolved variant, is still real drift.
|
|
144
|
+
*
|
|
145
|
+
* Today `missing-tool`/`tool-not-found` is the ONLY verdict a `path` tool can
|
|
146
|
+
* receive here, because discovery cannot see its version at all — so the state
|
|
147
|
+
* and reason conditions are unreachable as written. They are kept because every
|
|
148
|
+
* one of them can only make this guard NARROWER: a combination they reject falls
|
|
149
|
+
* through to the drift report, never past it. A future discovery that does read
|
|
150
|
+
* PATH would make them load-bearing rather than silently permissive.
|
|
151
|
+
*/
|
|
152
|
+
function unobservableOnPath(sensor, live) {
|
|
153
|
+
return sensor.command.resolution === 'path' && live.variantId === null
|
|
154
|
+
&& live.state === 'missing-tool' && live.reason === 'tool-not-found';
|
|
155
|
+
}
|
|
127
156
|
function staticCompatibilityCheck(sensor, live) {
|
|
128
157
|
if (!live)
|
|
129
158
|
return { ok: false, detail: 'live compatibility unavailable' };
|
|
159
|
+
if (unobservableOnPath(sensor, live))
|
|
160
|
+
return null;
|
|
130
161
|
if (live.variantId !== sensor.variantId) {
|
|
131
162
|
return { ok: false, detail: `compatibility drift: initialized ${sensor.variantId}, resolved ${live.variantId ?? live.state}` };
|
|
132
163
|
}
|
|
@@ -226,9 +257,11 @@ async function computeSensorStatus(cwd = process.cwd()) {
|
|
|
226
257
|
checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' }
|
|
227
258
|
: staticCompatibilityCheck(sensor, compatibility[name]) ?? checkStructuredCommand(sensor.command, packageRoot, sensor.assets);
|
|
228
259
|
}
|
|
260
|
+
const uninitialized = uninitializedSensors(manifest, compatibility);
|
|
229
261
|
return {
|
|
230
262
|
overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
|
|
231
263
|
pack: manifest.pack, checks,
|
|
264
|
+
...(uninitialized ? { uninitialized } : {}),
|
|
232
265
|
...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }),
|
|
233
266
|
};
|
|
234
267
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.describeUnresolved = describeUnresolved;
|
|
4
|
+
/**
|
|
5
|
+
* `sensor-a: missing-tool/tool-not-found; sensor-b: incompatible/...` — which
|
|
6
|
+
* sensor and why. Before #172 an unresolvable sensor produced one code for the
|
|
7
|
+
* whole pack, so an operator could not tell that the other sensors were fine.
|
|
8
|
+
*
|
|
9
|
+
* Its own module so the command wiring and `initSensors` share one formatting,
|
|
10
|
+
* and so neither has to import the other to get it.
|
|
11
|
+
*/
|
|
12
|
+
function describeUnresolved(unresolved) {
|
|
13
|
+
if (!unresolved || unresolved.length === 0)
|
|
14
|
+
return '';
|
|
15
|
+
return [...unresolved]
|
|
16
|
+
.sort((left, right) => left.sensor.localeCompare(right.sensor))
|
|
17
|
+
.map(item => `${item.sensor}: ${item.state}/${item.reason}`)
|
|
18
|
+
.join('; ');
|
|
19
|
+
}
|
|
@@ -56,7 +56,7 @@ function aggregateTrackStatus(planRoot, plan) {
|
|
|
56
56
|
const observed = (0, store_1.readJournal)(ref.worktreePath, ref.branch);
|
|
57
57
|
tracks[ref.trackId] = {
|
|
58
58
|
phase: ref.phase,
|
|
59
|
-
gate: (0, gate_1.computeTrackGate)(observed.state, observed.corrupt, fingerprintFor(ref.worktreePath)),
|
|
59
|
+
gate: (0, gate_1.computeTrackGate)(observed.state, observed.corrupt, fingerprintFor(ref.worktreePath), observed.absent),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
return { cohort: deriveCohortPhase(refs), tracks };
|
|
@@ -464,7 +464,7 @@ class Supervisor {
|
|
|
464
464
|
if (freezing)
|
|
465
465
|
return this.attemptFreeze();
|
|
466
466
|
const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
|
|
467
|
-
const gate = (0, gate_1.computeGate)(r.state, r.corrupt, this.fingerprintNow);
|
|
467
|
+
const gate = (0, gate_1.computeGate)(r.state, r.corrupt, this.fingerprintNow, r.absent);
|
|
468
468
|
const liveJobs = r.state === null ? 1 : Object.values(r.state.jobs).filter((j) => LIVE.includes(j.executionState)).length;
|
|
469
469
|
// R7/C3/C4 (Task 12): un journal de PLAN con cohorte de tracks NUNCA
|
|
470
470
|
// declara `cycle.status = COMPLETE` por este camino genérico mientras
|
|
@@ -640,8 +640,11 @@ exports.Supervisor = Supervisor;
|
|
|
640
640
|
* generacion propia (cero huerfanos). */
|
|
641
641
|
async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)(), trackRuntime, dispatchAdmission) {
|
|
642
642
|
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
643
|
-
if (r.corrupt || r.state === null)
|
|
644
|
-
throw new Error(
|
|
643
|
+
if (r.corrupt || r.state === null) {
|
|
644
|
+
throw new Error(r.absent && !r.corrupt
|
|
645
|
+
? 'journal ausente: corre `awm watch --init --plan <plan>` primero'
|
|
646
|
+
: 'journal corrupto o ilegible: inspecciona .awm/journal antes de continuar');
|
|
647
|
+
}
|
|
645
648
|
if (r.state.schema !== 2 || !r.state.planBinding) {
|
|
646
649
|
throw new Error('journal legacy o sin binding compacto: `awm watch` no puede ejecutar ni despachar trabajo');
|
|
647
650
|
}
|
|
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ABSENT_JOURNAL_DETAIL = void 0;
|
|
7
|
+
exports.journalPresence = journalPresence;
|
|
6
8
|
exports.assertJournalInitPaths = assertJournalInitPaths;
|
|
7
9
|
exports.initJournal = initJournal;
|
|
8
10
|
exports.initBoundJournal = initBoundJournal;
|
|
@@ -18,6 +20,16 @@ const types_1 = require("./types");
|
|
|
18
20
|
const paths_1 = require("./paths");
|
|
19
21
|
const identity_1 = require("../plan/identity");
|
|
20
22
|
const validate_1 = require("../plan/validate");
|
|
23
|
+
function journalPresence(read) {
|
|
24
|
+
if (read.corrupt)
|
|
25
|
+
return 'corrupt';
|
|
26
|
+
if (read.absent || read.state === null)
|
|
27
|
+
return 'absent';
|
|
28
|
+
return 'present';
|
|
29
|
+
}
|
|
30
|
+
/** What an absent journal actually needs, naming the command rather than leaving
|
|
31
|
+
* the operator to infer it — and saying who this interlock is for. */
|
|
32
|
+
exports.ABSENT_JOURNAL_DETAIL = 'journal ausente (no corrupto): el interlock desatendido exige un journal ligado — corre `awm watch --init --plan <plan>`. Un ciclo interactivo no requiere journal.';
|
|
21
33
|
/** Schema 1 evoluciono de forma aditiva durante R1/R5. Normalizamos solamente
|
|
22
34
|
* campos que antes no existian; evidencia legacy queda deliberadamente con
|
|
23
35
|
* fingerprint vacio para que el gate la considere stale, nunca certificada.
|
|
@@ -188,28 +200,35 @@ function initBoundJournal(repoRoot, branch, binding, report) {
|
|
|
188
200
|
}
|
|
189
201
|
}
|
|
190
202
|
/** Lectura corrupt-aware (R1.6): sintaxis invalida O shape invalido => corrupt:true.
|
|
191
|
-
*
|
|
203
|
+
* Un journal que nunca se creo es `absent`, jamas `corrupt`.
|
|
204
|
+
* Los CONSUMIDORES deciden: consultas muestran el estado; gate/reconcile bloquean. */
|
|
192
205
|
function readJournal(repoRoot, branch) {
|
|
193
206
|
const sp = (0, paths_1.statePath)(repoRoot, branch);
|
|
194
207
|
let raw;
|
|
195
208
|
try {
|
|
209
|
+
// Both tree assertions RETURN on ENOENT and throw a plain Error for an
|
|
210
|
+
// unsafe or linked segment, so the only ENOENT that can reach here comes
|
|
211
|
+
// from the state file itself. An unsafe tree is therefore never reported
|
|
212
|
+
// as absence — the same rule `watchJournalStatus` already states.
|
|
196
213
|
assertJournalTree(repoRoot, branch);
|
|
197
214
|
assertJournalFileNotSymlink(sp);
|
|
198
215
|
raw = fs_1.default.readFileSync(sp, 'utf8');
|
|
199
216
|
}
|
|
200
|
-
catch {
|
|
201
|
-
return
|
|
217
|
+
catch (error) {
|
|
218
|
+
return error.code === 'ENOENT'
|
|
219
|
+
? { state: null, corrupt: false, absent: true }
|
|
220
|
+
: { state: null, corrupt: true, absent: false };
|
|
202
221
|
}
|
|
203
222
|
let parsed;
|
|
204
223
|
try {
|
|
205
224
|
parsed = normalizeSchemaOne(JSON.parse(raw));
|
|
206
225
|
}
|
|
207
226
|
catch {
|
|
208
|
-
return { state: null, corrupt: true, raw };
|
|
227
|
+
return { state: null, corrupt: true, absent: false, raw };
|
|
209
228
|
}
|
|
210
229
|
if (!(0, types_1.isWellFormedState)(parsed))
|
|
211
|
-
return { state: null, corrupt: true, raw };
|
|
212
|
-
return { state: parsed, corrupt: false, raw };
|
|
230
|
+
return { state: null, corrupt: true, absent: false, raw };
|
|
231
|
+
return { state: parsed, corrupt: false, absent: false, raw };
|
|
213
232
|
}
|
|
214
233
|
/** Escritura canonica: SOLO el supervisor la invoca (single-writer). CAS por
|
|
215
234
|
* revision monotonica: el snapshot que traes debe ser el vigente. */
|
|
@@ -219,6 +238,11 @@ function writeJournal(repoRoot, branch, state) {
|
|
|
219
238
|
const current = readJournal(repoRoot, branch);
|
|
220
239
|
if (current.corrupt)
|
|
221
240
|
throw new Error('journal corrupto: no se escribe sobre corrupcion (R1.6)');
|
|
241
|
+
// Absence used to arrive here as `corrupt`, and this guard is what kept
|
|
242
|
+
// initialization create-only. Naming it explicitly preserves that exactly:
|
|
243
|
+
// only initJournal/initBoundJournal may bring a journal into existence.
|
|
244
|
+
if (current.absent)
|
|
245
|
+
throw new Error('journal inexistente: la creacion es de initJournal/initBoundJournal, no de writeJournal');
|
|
222
246
|
if (current.state !== null && current.state.revision !== state.revision) {
|
|
223
247
|
throw new Error(`revision desactualizada: disco=${current.state.revision} propuesta=${state.revision}`);
|
|
224
248
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
10
|
+
const query_1 = require("../../../src/commands/job/query");
|
|
11
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
12
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
13
|
+
const types_1 = require("../../../src/core/journal/types");
|
|
14
|
+
// #173. A journal that was never created is a different FACT from a damaged
|
|
15
|
+
// one, and needs a different response: absence is resolved by
|
|
16
|
+
// `awm watch --init --plan`, corruption by inspecting the file. `readJournal`
|
|
17
|
+
// collapsed both into `corrupt: true`, so a healthy repository with no journal
|
|
18
|
+
// told the operator its state file was unreadable.
|
|
19
|
+
//
|
|
20
|
+
// These are two separate cases with two separate assertions on purpose: a single
|
|
21
|
+
// test on "not pass" would not distinguish them, which is how this survived.
|
|
22
|
+
const BRANCH = 'main';
|
|
23
|
+
describe('an absent journal is not a corrupt one', () => {
|
|
24
|
+
let repo;
|
|
25
|
+
let previous;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-173-'));
|
|
28
|
+
previous = { home: process.env.HOME, awmHome: process.env.AWM_HOME };
|
|
29
|
+
process.env.HOME = path_1.default.join(repo, 'operator-home');
|
|
30
|
+
process.env.AWM_HOME = path_1.default.join(repo, 'awm-home');
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const [key, value] of [['HOME', previous.home], ['AWM_HOME', previous.awmHome]]) {
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
delete process.env[key];
|
|
36
|
+
else
|
|
37
|
+
process.env[key] = value;
|
|
38
|
+
}
|
|
39
|
+
fs_1.default.rmSync(repo, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
function writeState(body) {
|
|
42
|
+
fs_1.default.mkdirSync((0, paths_1.journalDir)(repo, BRANCH), { recursive: true });
|
|
43
|
+
fs_1.default.writeFileSync((0, paths_1.statePath)(repo, BRANCH), body);
|
|
44
|
+
}
|
|
45
|
+
it('reports a repository that never had a journal as absent, never corrupt', () => {
|
|
46
|
+
const read = (0, store_1.readJournal)(repo, BRANCH);
|
|
47
|
+
expect(read).toEqual({ state: null, corrupt: false, absent: true });
|
|
48
|
+
expect((0, store_1.journalPresence)(read)).toBe('absent');
|
|
49
|
+
});
|
|
50
|
+
it.each([
|
|
51
|
+
['truncated JSON', '{"schema":2,"branch":"'],
|
|
52
|
+
['well-formed JSON of the wrong shape', '{"nope":1}'],
|
|
53
|
+
['an empty file', ''],
|
|
54
|
+
])('still reports %s as corrupt, not absent', (_case, body) => {
|
|
55
|
+
writeState(body);
|
|
56
|
+
const read = (0, store_1.readJournal)(repo, BRANCH);
|
|
57
|
+
expect(read.corrupt).toBe(true);
|
|
58
|
+
expect(read.absent).toBe(false);
|
|
59
|
+
expect((0, store_1.journalPresence)(read)).toBe('corrupt');
|
|
60
|
+
});
|
|
61
|
+
// The adversarial case this change had to survive: `readFileSync` on a
|
|
62
|
+
// dangling symlink raises ENOENT, so a naive ENOENT check would report an
|
|
63
|
+
// attacker-placed link as an innocent absence. The symlink assertion runs
|
|
64
|
+
// first and throws without an errno, which is what keeps that impossible.
|
|
65
|
+
it('never reports a symlinked state file as absence, even a dangling one', () => {
|
|
66
|
+
fs_1.default.mkdirSync((0, paths_1.journalDir)(repo, BRANCH), { recursive: true });
|
|
67
|
+
fs_1.default.symlinkSync(path_1.default.join(repo, 'no-such-target'), (0, paths_1.statePath)(repo, BRANCH));
|
|
68
|
+
const read = (0, store_1.readJournal)(repo, BRANCH);
|
|
69
|
+
expect(read.corrupt).toBe(true);
|
|
70
|
+
expect(read.absent).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('never reports a symlinked journal directory as absence', () => {
|
|
73
|
+
fs_1.default.mkdirSync(path_1.default.join(repo, 'elsewhere'), { recursive: true });
|
|
74
|
+
fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
|
|
75
|
+
fs_1.default.symlinkSync(path_1.default.join(repo, 'elsewhere'), path_1.default.join(repo, '.awm', 'journal'));
|
|
76
|
+
const read = (0, store_1.readJournal)(repo, BRANCH);
|
|
77
|
+
expect(read.corrupt).toBe(true);
|
|
78
|
+
expect(read.absent).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
// Absence used to arrive at writeJournal as `corrupt`, and that is what kept
|
|
81
|
+
// journal creation exclusive to initJournal/initBoundJournal. Naming it must
|
|
82
|
+
// not have loosened that.
|
|
83
|
+
it('still refuses to create a journal through the write path', () => {
|
|
84
|
+
expect(() => (0, store_1.writeJournal)(repo, BRANCH, (0, types_1.emptyState)(BRANCH)))
|
|
85
|
+
.toThrow(/journal inexistente: la creacion es de initJournal/);
|
|
86
|
+
expect(fs_1.default.existsSync((0, paths_1.statePath)(repo, BRANCH))).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
it('reports absence through the job queries without dereferencing a null state', () => {
|
|
89
|
+
expect((0, query_1.queryPs)(repo, BRANCH)).toEqual({ corruptState: false, journal: 'absent', jobs: [] });
|
|
90
|
+
expect((0, query_1.queryList)(repo, BRANCH)).toEqual({ corruptState: false, journal: 'absent', cycleStatus: null, jobs: [] });
|
|
91
|
+
expect((0, query_1.queryShow)(repo, BRANCH, 'j1')).toEqual({ corruptState: false, journal: 'absent', job: null });
|
|
92
|
+
});
|
|
93
|
+
it('reports corruption through the job queries with corruptState still true', () => {
|
|
94
|
+
writeState('{"schema":2,"branch":"');
|
|
95
|
+
expect((0, query_1.queryPs)(repo, BRANCH)).toEqual({ corruptState: true, journal: 'corrupt', jobs: [] });
|
|
96
|
+
expect((0, query_1.queryList)(repo, BRANCH)).toEqual({ corruptState: true, journal: 'corrupt', cycleStatus: null, jobs: [] });
|
|
97
|
+
expect((0, query_1.queryShow)(repo, BRANCH, 'j1')).toEqual({ corruptState: true, journal: 'corrupt', job: null });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('the gates name absence and still fail closed', () => {
|
|
101
|
+
const fingerprint = () => 'fp';
|
|
102
|
+
it('reports an absent journal as absent, and names the command that creates one', () => {
|
|
103
|
+
const gate = (0, gate_1.computeGate)(null, false, fingerprint, true);
|
|
104
|
+
expect(gate.pass).toBe(false);
|
|
105
|
+
expect(gate.reasons).toEqual([{ category: 'absent', detail: store_1.ABSENT_JOURNAL_DETAIL }]);
|
|
106
|
+
expect(gate.reasons[0].detail).toContain('awm watch --init --plan');
|
|
107
|
+
// The interlock has no plan binding to read an execution mode from when
|
|
108
|
+
// the journal is missing, so it does not certify on absence either.
|
|
109
|
+
expect(gate.reasons[0].detail).toContain('interactivo');
|
|
110
|
+
});
|
|
111
|
+
it('reports a corrupt journal as corrupt even when absence is also claimed', () => {
|
|
112
|
+
expect((0, gate_1.computeGate)(null, true, fingerprint, true).reasons)
|
|
113
|
+
.toEqual([expect.objectContaining({ category: 'corrupt' })]);
|
|
114
|
+
});
|
|
115
|
+
// Proves the 60 existing call sites are unchanged: without the new argument a
|
|
116
|
+
// null state still fails closed exactly as before.
|
|
117
|
+
it('keeps every caller that omits the new argument failing closed as corrupt', () => {
|
|
118
|
+
expect((0, gate_1.computeGate)(null, false, fingerprint).pass).toBe(false);
|
|
119
|
+
expect((0, gate_1.computeGate)(null, false, fingerprint).reasons)
|
|
120
|
+
.toEqual([expect.objectContaining({ category: 'corrupt' })]);
|
|
121
|
+
});
|
|
122
|
+
it('distinguishes the same two facts for a track gate', () => {
|
|
123
|
+
expect((0, gate_1.computeTrackGate)(null, false, fingerprint, true).reasons)
|
|
124
|
+
.toEqual([{ category: 'absent-state', detail: store_1.ABSENT_JOURNAL_DETAIL }]);
|
|
125
|
+
expect((0, gate_1.computeTrackGate)(null, true, fingerprint, true).reasons)
|
|
126
|
+
.toEqual([expect.objectContaining({ category: 'corrupt-state' })]);
|
|
127
|
+
expect((0, gate_1.computeTrackGate)(null, false, fingerprint).reasons)
|
|
128
|
+
.toEqual([expect.objectContaining({ category: 'corrupt-state' })]);
|
|
129
|
+
});
|
|
130
|
+
it('does not report absence for a journal that is present', () => {
|
|
131
|
+
const gate = (0, gate_1.computeGate)((0, types_1.emptyState)('main'), false, fingerprint, false);
|
|
132
|
+
expect(gate.reasons).not.toContainEqual(expect.objectContaining({ category: 'absent' }));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -128,12 +128,16 @@ describe('planSensorBootstrap', () => {
|
|
|
128
128
|
await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors', packageRoot: 'packages/web' });
|
|
129
129
|
expect(live_1.resolveParsedPackCompatibility).toHaveBeenCalledWith(path_1.default.resolve(root, 'packages/web'), pack);
|
|
130
130
|
});
|
|
131
|
-
|
|
131
|
+
// #172 narrowed this: one unresolvable sensor no longer aborts a pack whose
|
|
132
|
+
// other sensors resolved (see partial-pack-bootstrap.test.ts). What still
|
|
133
|
+
// blocks is a pack where sensors apply and NONE of them resolve, which is a
|
|
134
|
+
// different condition from a pack with no applicable sensor at all.
|
|
135
|
+
it('blocks project-sensors when sensors apply and compatibility can select no variant at all', async () => {
|
|
132
136
|
const variant = { id: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
|
|
133
137
|
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
134
138
|
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } } });
|
|
135
|
-
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } }, sensors: { lint: { variantId: null } } });
|
|
136
|
-
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'sensor-
|
|
139
|
+
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } }, sensors: { lint: { state: 'missing-tool', reason: 'tool-not-found', variantId: null } } });
|
|
140
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'no-sensor-resolvable', changes: [] });
|
|
137
141
|
});
|
|
138
142
|
it('omits explicit-opt-in sensors that are not applicable while retaining mandatory gates', async () => {
|
|
139
143
|
const variant = { id: 'main', command: { executable: 'tool', resolution: 'path', args: ['run'] }, assets: [] };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn(), error: jest.fn() } }));
|
|
3
|
+
jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
|
|
4
4
|
jest.mock('picocolors', () => ({ green: (s) => s, yellow: (s) => s, red: (s) => s }));
|
|
5
5
|
jest.mock('../../../src/commands/sensors/run', () => ({ runSensors: jest.fn() }));
|
|
6
6
|
jest.mock('../../../src/commands/sensors/init', () => ({ initSensors: jest.fn() }));
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
jest.mock('../../../src/commands/sensors/project', () => ({ resolveSensorProject: jest.fn() }));
|
|
4
|
+
jest.mock('../../../src/commands/sensors/detection', () => ({ detectStack: jest.fn() }));
|
|
5
|
+
jest.mock('../../../src/core/registries', () => ({ listRegistries: jest.fn(() => []) }));
|
|
6
|
+
jest.mock('../../../src/commands/sensors/compatibility/pack-source', () => ({ listPackSources: jest.fn() }));
|
|
7
|
+
jest.mock('../../../src/commands/sensors/compatibility/contract', () => ({ ...jest.requireActual('../../../src/commands/sensors/compatibility/contract'), parseSensorPack: jest.fn() }));
|
|
8
|
+
jest.mock('../../../src/commands/sensors/compatibility/live', () => ({ resolveParsedPackCompatibility: jest.fn() }));
|
|
9
|
+
jest.mock('../../../src/commands/sensors/compatibility/materialize', () => ({ materializePortableSensors: jest.fn() }));
|
|
10
|
+
jest.mock('../../../src/commands/sensors/compatibility/safe-file', () => ({ writeProjectFile: jest.fn(), withProjectLease: jest.fn((_root, operation) => operation()) }));
|
|
11
|
+
const bootstrap_1 = require("../../../src/commands/sensors/bootstrap");
|
|
12
|
+
const unresolved_1 = require("../../../src/commands/sensors/unresolved");
|
|
13
|
+
const project_1 = require("../../../src/commands/sensors/project");
|
|
14
|
+
const detection_1 = require("../../../src/commands/sensors/detection");
|
|
15
|
+
const pack_source_1 = require("../../../src/commands/sensors/compatibility/pack-source");
|
|
16
|
+
const contract_1 = require("../../../src/commands/sensors/compatibility/contract");
|
|
17
|
+
const live_1 = require("../../../src/commands/sensors/compatibility/live");
|
|
18
|
+
// #172. One applicable sensor whose tool is absent used to abort the entire
|
|
19
|
+
// manifest, including the sensors that DID resolve — so a project whose only gap
|
|
20
|
+
// was semgrep could not configure sensors, could not pass preflight, and could
|
|
21
|
+
// not be handed off. A missing tool is a gap in one sensor, not grounds to refuse
|
|
22
|
+
// the pack.
|
|
23
|
+
const root = '/project';
|
|
24
|
+
const missing = { state: 'missing', projectRoot: root, manifestPath: `${root}/.awm/sensors.json` };
|
|
25
|
+
const source = { path: '/registry/sensor-packs/js-ts/pack.json', content: '{}', registry: { name: 'baseline', remote: 'local', contentRoot: '/registry' } };
|
|
26
|
+
const lintVariant = { id: 'eslint-10', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
|
|
27
|
+
const securityVariant = { id: 'semgrep-js-ts', command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] }, assets: ['.semgrep.awm.yml'] };
|
|
28
|
+
function resolvedLint() {
|
|
29
|
+
return { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: '10.4.1', runtimeVersion: '24.0.0', certifiedRange: '>=10 <11', evidence: [] };
|
|
30
|
+
}
|
|
31
|
+
function unresolvedSensor(state, reason) {
|
|
32
|
+
return { state, reason, variantId: null, toolVersion: null, runtimeVersion: null, certifiedRange: null, evidence: [] };
|
|
33
|
+
}
|
|
34
|
+
/** Wire the mocked pack + live resolution for one planning run. */
|
|
35
|
+
function pack(sensors, live) {
|
|
36
|
+
const parsed = { schemaVersion: 2, name: 'js-ts', sensors };
|
|
37
|
+
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
38
|
+
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: parsed });
|
|
39
|
+
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: parsed, sensors: live });
|
|
40
|
+
}
|
|
41
|
+
describe('project-sensors bootstrap with a partially resolvable pack', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
jest.clearAllMocks();
|
|
44
|
+
project_1.resolveSensorProject.mockReturnValue(missing);
|
|
45
|
+
detection_1.detectStack.mockReturnValue({ pack: 'js-ts', indicators: ['package.json'] });
|
|
46
|
+
});
|
|
47
|
+
it('writes the sensors that resolved and reports by name the applicable one that did not', async () => {
|
|
48
|
+
pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: resolvedLint(), security: unresolvedSensor('missing-tool', 'tool-not-found') });
|
|
49
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
50
|
+
expect(plan.kind).toBe('create');
|
|
51
|
+
const created = plan;
|
|
52
|
+
const manifest = created.manifest;
|
|
53
|
+
// The manifest contents are the assertion, not the exit code.
|
|
54
|
+
expect(Object.keys(manifest.sensors)).toEqual(['lint']);
|
|
55
|
+
expect(manifest.sensors.lint.variantId).toBe('eslint-10');
|
|
56
|
+
expect(created.unresolved).toEqual([{ sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' }]);
|
|
57
|
+
// Nothing is invented for the skipped sensor: no entry, and none of its assets.
|
|
58
|
+
expect(created.changes.map(change => change.path)).toEqual(['.awm/sensors.json']);
|
|
59
|
+
});
|
|
60
|
+
it('carries a sensor with no manifest entry rather than a disabled entry with fabricated evidence', async () => {
|
|
61
|
+
pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: resolvedLint(), security: unresolvedSensor('incompatible', 'no-operational-variant') });
|
|
62
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
63
|
+
const manifest = plan.manifest;
|
|
64
|
+
expect(manifest.sensors).not.toHaveProperty('security');
|
|
65
|
+
expect(JSON.stringify(manifest)).not.toContain('semgrep');
|
|
66
|
+
});
|
|
67
|
+
it('still blocks, naming every sensor, when sensors apply and none of them resolve', async () => {
|
|
68
|
+
pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: unresolvedSensor('missing-tool', 'tool-not-found'), security: unresolvedSensor('missing-tool', 'tool-not-found') });
|
|
69
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
70
|
+
expect(plan).toMatchObject({ kind: 'blocked', reason: 'no-sensor-resolvable', changes: [] });
|
|
71
|
+
expect(plan.unresolved).toEqual([
|
|
72
|
+
{ sensor: 'lint', state: 'missing-tool', reason: 'tool-not-found' },
|
|
73
|
+
{ sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' },
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
it('keeps the distinct honest-empty manifest when the pack declares no applicable sensor', async () => {
|
|
77
|
+
// This is NOT the same condition as "nothing resolved": it is the registry
|
|
78
|
+
// having no sensor for this stack, which preflight reports with its own
|
|
79
|
+
// remedy. Writing an empty manifest for an unresolvable pack would collide
|
|
80
|
+
// with that signal.
|
|
81
|
+
pack({ lint: { variants: [lintVariant] } }, { lint: unresolvedSensor('not-applicable', 'applicability-not-met') });
|
|
82
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
83
|
+
const manifest = plan.manifest;
|
|
84
|
+
expect(plan.kind).toBe('create');
|
|
85
|
+
expect(manifest.sensors).toEqual({});
|
|
86
|
+
expect(plan.unresolved).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
it('omits the unresolved field entirely when every applicable sensor resolved', async () => {
|
|
89
|
+
pack({ lint: { variants: [lintVariant] } }, { lint: resolvedLint() });
|
|
90
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
91
|
+
expect(plan.unresolved).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('describeUnresolved', () => {
|
|
95
|
+
it('names each sensor with its state and reason, in a stable order', () => {
|
|
96
|
+
expect((0, unresolved_1.describeUnresolved)([
|
|
97
|
+
{ sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' },
|
|
98
|
+
{ sensor: 'depcheck', state: 'incompatible', reason: 'no-operational-variant' },
|
|
99
|
+
])).toBe('depcheck: incompatible/no-operational-variant; security: missing-tool/tool-not-found');
|
|
100
|
+
});
|
|
101
|
+
it('is empty for nothing unresolved, so callers add no punctuation for an empty gap', () => {
|
|
102
|
+
expect((0, unresolved_1.describeUnresolved)(undefined)).toBe('');
|
|
103
|
+
expect((0, unresolved_1.describeUnresolved)([])).toBe('');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const status_1 = require("../../../src/commands/sensors/status");
|
|
10
|
+
const checks_1 = require("../../../src/commands/preflight/checks");
|
|
11
|
+
// #172. `checks` only ever covered what the manifest declares, so a pack sensor
|
|
12
|
+
// that applies to this project and could not be initialized was simply absent —
|
|
13
|
+
// indistinguishable from one the pack never applied. These tests hold the gap
|
|
14
|
+
// visible and attributable in every `awm sensors status`, not only in the output
|
|
15
|
+
// of the `init` that skipped it.
|
|
16
|
+
//
|
|
17
|
+
// Isolated HOME/AWM_HOME per test: nothing here may read or write the real
|
|
18
|
+
// `~/.awm`, which belongs to the installer.
|
|
19
|
+
const ESLINT_VARIANT = {
|
|
20
|
+
id: 'eslint-10',
|
|
21
|
+
priority: 100,
|
|
22
|
+
requirements: { tool: 'eslint', toolRange: '>=10.0.0 <12.0.0', runtime: 'node', runtimeRange: '>=20.0.0' },
|
|
23
|
+
certifiedRange: '>=10.0.0 <11.0.0',
|
|
24
|
+
command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
|
|
25
|
+
assets: [],
|
|
26
|
+
formatter: 'eslint',
|
|
27
|
+
probe: { kind: 'config-present' },
|
|
28
|
+
};
|
|
29
|
+
const SEMGREP_VARIANT = {
|
|
30
|
+
id: 'semgrep-js-ts',
|
|
31
|
+
priority: 10,
|
|
32
|
+
requirements: { tool: 'semgrep', toolRange: '>=1.0.0', runtime: 'node', runtimeRange: '>=20.0.0' },
|
|
33
|
+
certifiedRange: '>=1.0.0',
|
|
34
|
+
command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] },
|
|
35
|
+
assets: [],
|
|
36
|
+
formatter: 'semgrep',
|
|
37
|
+
probe: { kind: 'version' },
|
|
38
|
+
};
|
|
39
|
+
function packJson(sensors) {
|
|
40
|
+
const detector = Object.keys(sensors)[0];
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
schemaVersion: 2, name: 'js-ts', description: '#172 fixture', detects: ['package.json'], sensors,
|
|
43
|
+
coverage: {
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
classes: { 'lint-errors': { description: 'Findings', detectors: [{ sensor: detector }], remedy: { summary: 'Configure', command: 'awm sensors init --pack js-ts' } } },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function lintEntry() {
|
|
50
|
+
return {
|
|
51
|
+
enabled: true, variantId: 'eslint-10',
|
|
52
|
+
command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
|
|
53
|
+
initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: '10.4.1', runtimeVersion: '24.0.0', certifiedRange: '>=10.0.0 <11.0.0', evidence: [] },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function semgrepEntry() {
|
|
57
|
+
return {
|
|
58
|
+
enabled: true, variantId: 'semgrep-js-ts',
|
|
59
|
+
command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] },
|
|
60
|
+
initializedCompatibility: { state: 'compatible-unverified', reason: 'operational-range-and-probe', variantId: 'semgrep-js-ts', toolVersion: '1.173.0', runtimeVersion: '24.0.0', certifiedRange: '>=1.0.0', evidence: [] },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
describe('sensor status for a partially initialized pack', () => {
|
|
64
|
+
let root;
|
|
65
|
+
let project;
|
|
66
|
+
let pathDir;
|
|
67
|
+
let previous;
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-172-'));
|
|
70
|
+
project = path_1.default.join(root, 'project');
|
|
71
|
+
pathDir = path_1.default.join(root, 'bin');
|
|
72
|
+
const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
|
|
73
|
+
fs_1.default.mkdirSync(registry, { recursive: true });
|
|
74
|
+
fs_1.default.mkdirSync(path_1.default.join(project, '.awm'), { recursive: true });
|
|
75
|
+
fs_1.default.mkdirSync(pathDir, { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'awm-home', 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'fixture' }]));
|
|
77
|
+
fs_1.default.writeFileSync(path_1.default.join(project, 'package.json'), JSON.stringify({ name: 'fixture-172', private: true }));
|
|
78
|
+
fs_1.default.writeFileSync(path_1.default.join(project, 'eslint.config.mjs'), 'export default [];\n');
|
|
79
|
+
fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', 'eslint'), { recursive: true });
|
|
80
|
+
fs_1.default.writeFileSync(path_1.default.join(project, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ name: 'eslint', version: '10.4.1' }));
|
|
81
|
+
fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', '.bin'), { recursive: true });
|
|
82
|
+
fs_1.default.writeFileSync(path_1.default.join(project, 'node_modules', '.bin', 'eslint'), '');
|
|
83
|
+
fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
|
|
84
|
+
lint: { applicability: { allFiles: ['package.json'] }, variants: [ESLINT_VARIANT] },
|
|
85
|
+
security: { applicability: { allFiles: ['package.json'] }, variants: [SEMGREP_VARIANT] },
|
|
86
|
+
}));
|
|
87
|
+
previous = { home: process.env.HOME, awmHome: process.env.AWM_HOME, path: process.env.PATH };
|
|
88
|
+
process.env.HOME = path_1.default.join(root, 'operator-home');
|
|
89
|
+
process.env.AWM_HOME = path_1.default.join(root, 'awm-home');
|
|
90
|
+
process.env.PATH = pathDir;
|
|
91
|
+
});
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
for (const [key, value] of [['HOME', previous.home], ['AWM_HOME', previous.awmHome], ['PATH', previous.path]]) {
|
|
94
|
+
if (value === undefined)
|
|
95
|
+
delete process.env[key];
|
|
96
|
+
else
|
|
97
|
+
process.env[key] = value;
|
|
98
|
+
}
|
|
99
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
function writeManifest(sensors) {
|
|
102
|
+
fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'sensors.json'), JSON.stringify({
|
|
103
|
+
schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' }, sensors,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
function installOnPath(tool) {
|
|
107
|
+
const file = path_1.default.join(pathDir, tool);
|
|
108
|
+
fs_1.default.writeFileSync(file, '#!/bin/sh\nexit 0\n');
|
|
109
|
+
fs_1.default.chmodSync(file, 0o755);
|
|
110
|
+
}
|
|
111
|
+
it('names the applicable sensor that has no manifest entry, with its live reason', async () => {
|
|
112
|
+
writeManifest({ lint: lintEntry() });
|
|
113
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
114
|
+
expect(Object.keys(status.checks)).toEqual(['lint']);
|
|
115
|
+
expect(status.uninitialized).toEqual({ security: { state: 'missing-tool', reason: 'tool-not-found' } });
|
|
116
|
+
});
|
|
117
|
+
it('does not let an uninitialized sensor change the verdict, which stays a gate-policy decision', async () => {
|
|
118
|
+
writeManifest({ lint: lintEntry() });
|
|
119
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
120
|
+
expect(status.overall).toBe('READY');
|
|
121
|
+
});
|
|
122
|
+
it('omits the field when every applicable pack sensor has an entry', async () => {
|
|
123
|
+
installOnPath('semgrep');
|
|
124
|
+
writeManifest({ lint: lintEntry(), security: semgrepEntry() });
|
|
125
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
126
|
+
expect(status.uninitialized).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
it('reports a not-applicable pack sensor as neither a check nor a gap', async () => {
|
|
129
|
+
const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
|
|
130
|
+
fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
|
|
131
|
+
lint: { applicability: { allFiles: ['package.json'] }, variants: [ESLINT_VARIANT] },
|
|
132
|
+
security: { applicability: { allFiles: ['never-present.toml'] }, variants: [SEMGREP_VARIANT] },
|
|
133
|
+
}));
|
|
134
|
+
writeManifest({ lint: lintEntry() });
|
|
135
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
136
|
+
expect(status.uninitialized).toBeUndefined();
|
|
137
|
+
expect(status.checks).not.toHaveProperty('security');
|
|
138
|
+
});
|
|
139
|
+
it('preflight names the gap and still passes the manifest check, so one missing tool is not a dead end', async () => {
|
|
140
|
+
fs_1.default.mkdirSync(path_1.default.join(project, '.git'));
|
|
141
|
+
writeManifest({ lint: lintEntry() });
|
|
142
|
+
const report = await (0, checks_1.preflight)(project);
|
|
143
|
+
const manifest = report.checks.find(check => check.id === 'manifest');
|
|
144
|
+
expect(manifest).toMatchObject({ ok: true });
|
|
145
|
+
expect(manifest?.detail).toContain('1 applicable sensor(s) not initialized: security');
|
|
146
|
+
});
|
|
147
|
+
// `discoverProjectEvidence` is called here without `pathToolVersion` because
|
|
148
|
+
// status never executes, so a `resolution: 'path'` tool is invisible to it.
|
|
149
|
+
// Calling that "drift" asserts something this command did not measure.
|
|
150
|
+
it('does not claim drift for a PATH tool it cannot observe, and defers to the PATH check', async () => {
|
|
151
|
+
installOnPath('semgrep');
|
|
152
|
+
fs_1.default.writeFileSync(path_1.default.join(project, '.semgrep.awm.yml'), 'rules: []\n');
|
|
153
|
+
writeManifest({ security: semgrepEntry() });
|
|
154
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
155
|
+
expect(status.checks.security).toEqual({ ok: true, detail: 'semgrep' });
|
|
156
|
+
expect(status.overall).toBe('READY');
|
|
157
|
+
});
|
|
158
|
+
it('still fails a PATH sensor whose executable is genuinely absent from PATH', async () => {
|
|
159
|
+
writeManifest({ security: semgrepEntry() });
|
|
160
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
161
|
+
expect(status.checks.security.ok).toBe(false);
|
|
162
|
+
expect(status.checks.security.detail).toMatch(/semgrep not found in PATH/);
|
|
163
|
+
});
|
|
164
|
+
it('still reports real drift when the live resolution selects a different variant', async () => {
|
|
165
|
+
const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
|
|
166
|
+
fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
|
|
167
|
+
lint: { applicability: { allFiles: ['package.json'] }, variants: [{ ...ESLINT_VARIANT, id: 'eslint-10-flat' }] },
|
|
168
|
+
}));
|
|
169
|
+
writeManifest({ lint: lintEntry() });
|
|
170
|
+
const status = await (0, status_1.computeSensorStatus)(project);
|
|
171
|
+
expect(status.checks.lint.ok).toBe(false);
|
|
172
|
+
expect(status.checks.lint.detail).toMatch(/compatibility drift: initialized eslint-10, resolved eslint-10-flat/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -83,7 +83,7 @@ describe('aggregateTrackStatus — agregado read-only (R9.5, R9.6)', () => {
|
|
|
83
83
|
expect(currentPlanState.tracks[0]).not.toHaveProperty('gate');
|
|
84
84
|
expect(fs_1.default.readFileSync((0, paths_1.statePath)(planRoot, planBranch), 'utf8')).toBe(planBefore);
|
|
85
85
|
});
|
|
86
|
-
test('journal de track
|
|
86
|
+
test('journal de track ausente nunca se descarta en silencio: gate rojo con absent-state', () => {
|
|
87
87
|
// ni siquiera se inicializa el journal del segundo track: readJournal
|
|
88
88
|
// sobre un directorio sin state.json => corrupt:true (R1.6).
|
|
89
89
|
const missingTrackRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-missing-'));
|
|
@@ -93,7 +93,10 @@ describe('aggregateTrackStatus — agregado read-only (R9.5, R9.6)', () => {
|
|
|
93
93
|
(0, store_1.writeJournal)(planRoot, planBranch, planState);
|
|
94
94
|
const out = (0, status_1.aggregateTrackStatus)(planRoot, (0, store_1.readJournal)(planRoot, planBranch).state);
|
|
95
95
|
expect(out.tracks.ghost.gate.pass).toBe(false);
|
|
96
|
-
|
|
96
|
+
// #173: the track journal here is ABSENT, not damaged. The gate is
|
|
97
|
+
// still red — it is never discarded in silence — but it now names the
|
|
98
|
+
// fact it observed instead of claiming corruption.
|
|
99
|
+
expect(out.tracks.ghost.gate.reasons).toEqual([{ category: 'absent-state', detail: expect.any(String) }]);
|
|
97
100
|
}
|
|
98
101
|
finally {
|
|
99
102
|
fs_1.default.rmSync(missingTrackRoot, { recursive: true, force: true });
|
|
@@ -180,7 +180,10 @@ describe('watch --init: plan-vs-repo mecanico', () => {
|
|
|
180
180
|
test('watch --init --plan bloquea un plan no valido antes de crear journal', () => {
|
|
181
181
|
const invalid = { state: 'invalid', diagnostics: [{ code: 'PLAN_SHAPE', message: 'bad' }] };
|
|
182
182
|
expect(() => (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: invalid })).toThrow(/válido/i);
|
|
183
|
-
|
|
183
|
+
// #173: this asserted `.corrupt` as a proxy for "no journal was created",
|
|
184
|
+
// which is the exact confusion that issue is about. The fact it means is
|
|
185
|
+
// absence, and that is now what it says.
|
|
186
|
+
expect((0, store_1.readJournal)(repo, 'rama')).toMatchObject({ state: null, corrupt: false, absent: true });
|
|
184
187
|
});
|
|
185
188
|
test('watch --init --plan rechaza un plan interactivo antes de crear un binding desatendido', () => {
|
|
186
189
|
const interactive = { ...validPlan('a'), executionMode: 'interactivo' };
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|