@yemi33/minions 0.1.2424 → 0.1.2426
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/bin/install-internal-minions.js +1209 -0
- package/bin/minions.js +75 -9
- package/dashboard/docs/typography.md +27 -12
- package/dashboard/js/command-center.js +13 -11
- package/dashboard/js/live-stream.js +1 -1
- package/dashboard/js/memory-search.js +388 -43
- package/dashboard/js/modal-qa.js +5 -5
- package/dashboard/js/qa.js +39 -26
- package/dashboard/js/refresh.js +37 -18
- package/dashboard/js/render-agents.js +14 -18
- package/dashboard/js/render-dispatch.js +14 -74
- package/dashboard/js/render-inbox.js +3 -3
- package/dashboard/js/render-meetings.js +8 -8
- package/dashboard/js/render-other.js +45 -20
- package/dashboard/js/render-pipelines.js +6 -6
- package/dashboard/js/render-plans.js +14 -14
- package/dashboard/js/render-prd.js +46 -46
- package/dashboard/js/render-prs.js +9 -65
- package/dashboard/js/render-schedules.js +5 -5
- package/dashboard/js/render-skills.js +6 -6
- package/dashboard/js/render-utils.js +4 -4
- package/dashboard/js/render-watches.js +4 -16
- package/dashboard/js/render-work-items.js +95 -107
- package/dashboard/js/settings.js +175 -74
- package/dashboard/layout.html +3 -2
- package/dashboard/pages/inbox.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/model-display.js +9 -0
- package/dashboard/shared/watches-source.js +43 -0
- package/dashboard/shared/welcome-popup.js +259 -0
- package/dashboard/slim/body.html +6 -6
- package/dashboard/slim/js/chat.js +4 -2
- package/dashboard/slim/js/history.js +10 -3
- package/dashboard/slim/js/modals-tiles.js +85 -16
- package/dashboard/slim/js/status.js +12 -16
- package/dashboard/slim/styles.css +24 -4
- package/dashboard/styles.css +62 -27
- package/dashboard-build.js +8 -2
- package/dashboard.js +3854 -2514
- package/docs/README.md +7 -5
- package/docs/api-errors.md +144 -0
- package/docs/auto-discovery.md +83 -61
- package/docs/capture-demos.js +173 -30
- package/docs/command-center.md +5 -1
- package/docs/completion-reports.md +127 -11
- package/docs/constants.md +15 -3
- package/docs/constellation-style-telemetry.md +2 -2
- package/docs/copilot-cli-schema.md +32 -22
- package/docs/cross-repo-plans.md +30 -30
- package/docs/demo/memory-system.html +1 -1
- package/docs/deprecated.json +132 -18
- package/docs/diagnostics-memory.md +8 -4
- package/docs/human-vs-automated.md +2 -2
- package/docs/index.html +9 -2
- package/docs/internal-install.md +212 -0
- package/docs/kb-pr3223-cascade-archiving.md +16 -0
- package/docs/kb-pr696-merge-conflict-docs.md +23 -0
- package/docs/kb-sweep.md +2 -2
- package/docs/keep-processes.md +8 -1
- package/docs/live-checkout-mode.md +1 -1
- package/docs/managed-spawn.md +21 -3
- package/docs/named-agents.md +3 -2
- package/docs/onboarding.md +26 -0
- package/docs/plan-lifecycle.md +32 -32
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
- package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
- package/docs/qa-runbook-lifecycle.md +67 -51
- package/docs/qa-runbooks.md +2 -2
- package/docs/runtime-adapters.md +101 -50
- package/docs/security.md +28 -0
- package/docs/self-improvement.md +47 -13
- package/docs/skills.md +29 -0
- package/docs/slim-ux/concepts.md +2 -2
- package/docs/specs/agent-configurability.md +43 -41
- package/docs/specs/agent-rename.md +13 -13
- package/docs/team-memory.md +283 -14
- package/docs/tutorials/01-install-and-connect.md +23 -0
- package/docs/tutorials/03-plan-driven-feature.md +3 -3
- package/docs/tutorials/04-runtimes-and-harness.md +18 -21
- package/docs/tutorials/08-operations-and-recovery.md +3 -2
- package/docs/tutorials/README.md +4 -3
- package/docs/visual-evidence-ci.md +103 -0
- package/docs/watches.md +35 -30
- package/docs/worktree-lifecycle.md +151 -13
- package/engine/acp-transport.js +1 -0
- package/engine/ado.js +80 -33
- package/engine/agent-api-validation.js +571 -0
- package/engine/agent-worker-pool.js +7 -6
- package/engine/api-contracts/agent-content.js +761 -0
- package/engine/api-contracts/cc-ops.js +219 -0
- package/engine/api-contracts/config-runtime.js +419 -0
- package/engine/api-contracts/core.js +138 -0
- package/engine/api-contracts/index.js +506 -0
- package/engine/api-contracts/orchestration.js +602 -0
- package/engine/api-contracts/pull-requests.js +323 -0
- package/engine/api-contracts/qa-process.js +764 -0
- package/engine/api-contracts/work-plan-prd.js +494 -0
- package/engine/api-validation.js +543 -0
- package/engine/cc-api-validation.js +586 -0
- package/engine/cc-worker-pool.js +9 -1
- package/engine/cleanup.js +393 -271
- package/engine/cli.js +65 -7
- package/engine/comment-classifier.js +70 -5
- package/engine/consolidation.js +123 -5
- package/engine/content-api-validation.js +606 -0
- package/engine/create-pr-worktree.js +22 -1
- package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
- package/engine/dispatch.js +33 -0
- package/engine/distribution.js +189 -0
- package/engine/execution-model.js +59 -0
- package/engine/features.js +12 -0
- package/engine/github.js +67 -25
- package/engine/harness-context.js +313 -0
- package/engine/inbox-store.js +1 -1
- package/engine/kb-sweep.js +161 -38
- package/engine/keep-process-sweep.js +43 -3
- package/engine/lifecycle.js +1066 -165
- package/engine/llm.js +4 -0
- package/engine/managed-spawn.js +463 -48
- package/engine/meeting.js +197 -34
- package/engine/memory-retrieval.js +280 -0
- package/engine/memory-store.js +821 -38
- package/engine/model-discovery.js +17 -3
- package/engine/pipeline.js +802 -14
- package/engine/plan-prd-validation.js +745 -0
- package/engine/playbook.js +206 -17
- package/engine/pooled-agent-process.js +6 -1
- package/engine/pr-action.js +1 -1
- package/engine/pr-issue-validation.js +653 -0
- package/engine/pr-resolve.js +75 -13
- package/engine/prd-store.js +39 -6
- package/engine/preflight.js +7 -2
- package/engine/process-utils.js +79 -31
- package/engine/projects.js +198 -12
- package/engine/promotion.js +371 -0
- package/engine/qa-from-prd.js +3 -0
- package/engine/qa-process-validation.js +572 -0
- package/engine/qa-runbooks.js +41 -37
- package/engine/qa-runners/maestro.js +11 -7
- package/engine/qa-runners/playwright.js +37 -14
- package/engine/qa-runners.js +9 -10
- package/engine/qa-runs.js +124 -11
- package/engine/qa-sessions.js +139 -40
- package/engine/quarantine-refs.js +81 -11
- package/engine/queries.js +163 -9
- package/engine/review-learning-backfill.js +482 -0
- package/engine/review-learning.js +1236 -0
- package/engine/runtimes/claude.js +60 -2
- package/engine/runtimes/codex.js +57 -16
- package/engine/runtimes/contract.js +21 -0
- package/engine/runtimes/copilot.js +51 -3
- package/engine/runtimes/index.js +1 -0
- package/engine/scheduler.js +201 -32
- package/engine/settings-validation.js +909 -0
- package/engine/shared.js +705 -208
- package/engine/spawn-agent.js +48 -21
- package/engine/steering-constraints.js +31 -0
- package/engine/steering-store.js +10 -2
- package/engine/steering.js +14 -2
- package/engine/timeout.js +404 -111
- package/engine/untrusted-fence.js +24 -10
- package/engine/watch-actions.js +7 -2
- package/engine/watches.js +353 -51
- package/engine/work-item-validation.js +561 -0
- package/engine/work-items-store.js +61 -1
- package/engine/worktree-gc.js +656 -37
- package/engine/worktree-preflight.js +154 -0
- package/engine.js +2068 -342
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +88 -41
- package/playbooks/build-fix-complex.md +3 -3
- package/playbooks/docs.md +2 -2
- package/playbooks/fix.md +18 -18
- package/playbooks/implement.md +18 -18
- package/playbooks/qa-session-draft.md +10 -12
- package/playbooks/qa-session-execute.md +16 -12
- package/playbooks/qa-validate.md +8 -7
- package/playbooks/shared-rules.md +45 -0
- package/playbooks/test.md +4 -0
- package/prompts/cc-system.md +1 -1
- package/skills/check-self-authored-review-comment/SKILL.md +2 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CC_API_LIMITS, PRESENCE_VISIBILITIES } = require('../cc-api-validation');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
owner: 'core',
|
|
7
|
+
routes: [
|
|
8
|
+
'GET /api/version',
|
|
9
|
+
'GET /api/routes',
|
|
10
|
+
'GET /api/status',
|
|
11
|
+
'GET /api/engine-log',
|
|
12
|
+
'GET /api/constellation/v1/snapshot',
|
|
13
|
+
'GET /state/<path>',
|
|
14
|
+
'GET /api/metrics',
|
|
15
|
+
'GET /api/tools',
|
|
16
|
+
'GET /api/browser-presence',
|
|
17
|
+
'POST /api/browser-presence',
|
|
18
|
+
'GET /api/status-stream',
|
|
19
|
+
'GET /api/health',
|
|
20
|
+
'POST /api/dashboard/restart',
|
|
21
|
+
],
|
|
22
|
+
bodyless: [
|
|
23
|
+
'POST /api/dashboard/restart',
|
|
24
|
+
],
|
|
25
|
+
overrides: {
|
|
26
|
+
'POST /api/dashboard/restart': {
|
|
27
|
+
audit: 'audited',
|
|
28
|
+
body: {
|
|
29
|
+
policy: 'optional',
|
|
30
|
+
fields: [],
|
|
31
|
+
rationale: 'The fixed operator action accepts either no body or an empty JSON object.',
|
|
32
|
+
},
|
|
33
|
+
constraints: [
|
|
34
|
+
{ kind: 'absent-or-empty-object', location: 'body' },
|
|
35
|
+
],
|
|
36
|
+
negativeTests: [
|
|
37
|
+
{ strategy: 'send-meaningful-object-body', expectedStatus: 400 },
|
|
38
|
+
{ strategy: 'send-non-object-json-body', expectedStatus: 400 },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
'GET /api/version': {
|
|
42
|
+
audit: 'audited',
|
|
43
|
+
noInputReason: 'Returns process and package version state; accepts no caller input.',
|
|
44
|
+
},
|
|
45
|
+
'GET /api/routes': {
|
|
46
|
+
audit: 'audited',
|
|
47
|
+
noInputReason: 'Returns the static executable route catalog; dynamic constraints are source references.',
|
|
48
|
+
},
|
|
49
|
+
'GET /api/status': {
|
|
50
|
+
audit: 'audited',
|
|
51
|
+
noInputReason: 'Returns the current dashboard snapshot; accepts no caller input.',
|
|
52
|
+
},
|
|
53
|
+
'GET /api/engine-log': {
|
|
54
|
+
audit: 'audited',
|
|
55
|
+
noInputReason: 'Returns the latest SQL-backed engine log entries; accepts no caller input.',
|
|
56
|
+
},
|
|
57
|
+
'GET /api/tools': {
|
|
58
|
+
audit: 'audited',
|
|
59
|
+
noInputReason: 'Returns the cached skills/commands/MCP inventory; accepts no caller input.',
|
|
60
|
+
},
|
|
61
|
+
'GET /api/constellation/v1/snapshot': {
|
|
62
|
+
audit: 'audited',
|
|
63
|
+
noInputReason: 'Returns the versioned bridge snapshot; accepts no caller input.',
|
|
64
|
+
},
|
|
65
|
+
'GET /state/<path>': {
|
|
66
|
+
audit: 'audited',
|
|
67
|
+
path: {
|
|
68
|
+
policy: 'required',
|
|
69
|
+
fields: [
|
|
70
|
+
{
|
|
71
|
+
name: 'path',
|
|
72
|
+
type: 'path',
|
|
73
|
+
required: true,
|
|
74
|
+
maxLength: 4096,
|
|
75
|
+
allowedValuesSource: 'dashboard.STATE_READ_ALLOWED_DIRS/STATE_READ_ALLOWED_FILES',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
constraints: [
|
|
80
|
+
{
|
|
81
|
+
kind: 'sandbox-membership',
|
|
82
|
+
base: 'queries.MINIONS_DIR',
|
|
83
|
+
traversalStatus: 403,
|
|
84
|
+
missingStatus: 404,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
kind: 'decoded-before-filesystem-access',
|
|
88
|
+
field: 'path',
|
|
89
|
+
location: 'path',
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
negativeTests: [
|
|
93
|
+
{ strategy: 'malformed-url-encoding', expectedStatus: 400 },
|
|
94
|
+
{ strategy: 'path-traversal', expectedStatus: 403 },
|
|
95
|
+
{ strategy: 'missing-path', expectedStatus: 404 },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
'GET /api/metrics': {
|
|
99
|
+
audit: 'audited',
|
|
100
|
+
noInputReason: 'Returns the current metrics projection; accepts no caller input.',
|
|
101
|
+
},
|
|
102
|
+
'GET /api/browser-presence': {
|
|
103
|
+
audit: 'audited',
|
|
104
|
+
noInputReason: 'Returns the current browser-presence snapshot; accepts no caller input.',
|
|
105
|
+
},
|
|
106
|
+
'GET /api/status-stream': {
|
|
107
|
+
audit: 'audited',
|
|
108
|
+
noInputReason: 'Opens the fixed status SSE stream; accepts no caller input.',
|
|
109
|
+
},
|
|
110
|
+
'GET /api/health': {
|
|
111
|
+
audit: 'audited',
|
|
112
|
+
noInputReason: 'Returns the lightweight process health snapshot; accepts no caller input.',
|
|
113
|
+
},
|
|
114
|
+
'POST /api/browser-presence': {
|
|
115
|
+
audit: 'audited',
|
|
116
|
+
body: {
|
|
117
|
+
policy: 'required',
|
|
118
|
+
fields: [
|
|
119
|
+
{ name: 'tabId', type: 'string', required: true, maxLength: CC_API_LIMITS.tabIdMaxChars },
|
|
120
|
+
{ name: 'closed', type: 'boolean', required: false },
|
|
121
|
+
{ name: 'url', type: 'string', required: false, maxLength: CC_API_LIMITS.presenceUrlMaxChars },
|
|
122
|
+
{
|
|
123
|
+
name: 'visibility',
|
|
124
|
+
type: 'string',
|
|
125
|
+
required: false,
|
|
126
|
+
allowedValues: PRESENCE_VISIBILITIES,
|
|
127
|
+
maxLength: CC_API_LIMITS.visibilityMaxChars,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
negativeTests: [
|
|
132
|
+
{ strategy: 'omit-or-malform-tab-id', expectedStatus: 400 },
|
|
133
|
+
{ strategy: 'use-wrong-field-types', expectedStatus: 400 },
|
|
134
|
+
{ strategy: 'exceed-string-limits', expectedStatus: 400 },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const OWNER_MODULES = [
|
|
4
|
+
require('./core'),
|
|
5
|
+
require('./work-plan-prd'),
|
|
6
|
+
require('./pull-requests'),
|
|
7
|
+
require('./config-runtime'),
|
|
8
|
+
require('./orchestration'),
|
|
9
|
+
require('./qa-process'),
|
|
10
|
+
require('./agent-content'),
|
|
11
|
+
require('./cc-ops'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const OWNER_NAMES = OWNER_MODULES.map(moduleContract => String(moduleContract.owner || '').trim());
|
|
15
|
+
if (new Set(OWNER_NAMES).size !== OWNER_NAMES.length) {
|
|
16
|
+
throw new Error('API contract ownership modules must use unique owner names');
|
|
17
|
+
}
|
|
18
|
+
const OWNERS = Object.freeze(OWNER_NAMES);
|
|
19
|
+
const LOCATION_POLICIES = new Set(['none', 'optional', 'required', 'pending-audit']);
|
|
20
|
+
const AUDIT_STATUSES = new Set(['audited', 'pending-audit']);
|
|
21
|
+
|
|
22
|
+
function canonicalPathTemplate(value) {
|
|
23
|
+
const pathTemplate = String(value || '').trim();
|
|
24
|
+
if (!pathTemplate.startsWith('/')) {
|
|
25
|
+
throw new Error(`Route path template must start with "/": ${pathTemplate || '(empty)'}`);
|
|
26
|
+
}
|
|
27
|
+
return pathTemplate.replace(/:([A-Za-z][A-Za-z0-9_]*)/g, '<$1>');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalRouteKey(route) {
|
|
31
|
+
if (typeof route === 'string') {
|
|
32
|
+
const match = route.trim().match(/^([A-Za-z]+)\s+(.+)$/);
|
|
33
|
+
if (!match) throw new Error(`Invalid route key: ${route}`);
|
|
34
|
+
return `${match[1].toUpperCase()} ${canonicalPathTemplate(match[2])}`;
|
|
35
|
+
}
|
|
36
|
+
if (!route || typeof route !== 'object') throw new Error('Route must be an object or canonical key');
|
|
37
|
+
const method = String(route.method || '').trim().toUpperCase();
|
|
38
|
+
if (!method) throw new Error('Route method is required');
|
|
39
|
+
let pathTemplate;
|
|
40
|
+
if (typeof route.path === 'string') {
|
|
41
|
+
pathTemplate = route.path;
|
|
42
|
+
} else if (route.path instanceof RegExp) {
|
|
43
|
+
pathTemplate = route.template || route.pathTemplate;
|
|
44
|
+
if (!pathTemplate) {
|
|
45
|
+
throw new Error(`${method} regex route requires an explicit path template`);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`${method} route path must be a string or RegExp`);
|
|
49
|
+
}
|
|
50
|
+
return `${method} ${canonicalPathTemplate(pathTemplate)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function splitCanonicalKey(key) {
|
|
54
|
+
const canonical = canonicalRouteKey(key);
|
|
55
|
+
const separator = canonical.indexOf(' ');
|
|
56
|
+
return {
|
|
57
|
+
key: canonical,
|
|
58
|
+
method: canonical.slice(0, separator),
|
|
59
|
+
path: canonical.slice(separator + 1),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pathFields(pathTemplate) {
|
|
64
|
+
return [...pathTemplate.matchAll(/<([A-Za-z][A-Za-z0-9_]*)>/g)].map(match => ({
|
|
65
|
+
name: match[1],
|
|
66
|
+
type: match[1].toLowerCase() === 'path' ? 'path' : 'string',
|
|
67
|
+
required: true,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cloneSerializable(value) {
|
|
72
|
+
return JSON.parse(JSON.stringify(value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeFields(fields, key, location) {
|
|
76
|
+
if (fields === undefined) return [];
|
|
77
|
+
if (!Array.isArray(fields)) throw new Error(`${key} ${location}.fields must be an array`);
|
|
78
|
+
return fields.map(field => {
|
|
79
|
+
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
80
|
+
throw new Error(`${key} ${location} field must be an object`);
|
|
81
|
+
}
|
|
82
|
+
const name = String(field.name || '').trim();
|
|
83
|
+
const type = String(field.type || '').trim();
|
|
84
|
+
if (!name || !type) throw new Error(`${key} ${location} fields require name and type`);
|
|
85
|
+
if (field.allowedValuesSource !== undefined
|
|
86
|
+
&& (typeof field.allowedValuesSource !== 'string' || !field.allowedValuesSource.trim())) {
|
|
87
|
+
throw new Error(`${key} ${location}.${name} allowedValuesSource must be a non-empty string`);
|
|
88
|
+
}
|
|
89
|
+
return cloneSerializable({ ...field, name, type });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeLocation(value, fallback, key, location) {
|
|
94
|
+
const source = value === undefined ? fallback : value;
|
|
95
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
96
|
+
throw new Error(`${key} ${location} contract must be an object`);
|
|
97
|
+
}
|
|
98
|
+
const policy = String(source.policy || '').trim();
|
|
99
|
+
if (!LOCATION_POLICIES.has(policy)) {
|
|
100
|
+
throw new Error(`${key} ${location} has invalid policy "${policy}"`);
|
|
101
|
+
}
|
|
102
|
+
const normalized = {
|
|
103
|
+
policy,
|
|
104
|
+
fields: normalizeFields(source.fields, key, location),
|
|
105
|
+
};
|
|
106
|
+
if (source.rationale) normalized.rationale = String(source.rationale);
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultPendingNegativeTest() {
|
|
111
|
+
return {
|
|
112
|
+
strategy: 'reject-malformed-or-invalid-input',
|
|
113
|
+
expectedStatus: '4xx',
|
|
114
|
+
audit: 'pending-audit',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildRouteContract(owner, key, bodyless, override = {}) {
|
|
119
|
+
const parsed = splitCanonicalKey(key);
|
|
120
|
+
const stateChanging = !['GET', 'HEAD', 'OPTIONS'].includes(parsed.method);
|
|
121
|
+
const routePathFields = pathFields(parsed.path);
|
|
122
|
+
const bodylessRoute = bodyless.has(parsed.key);
|
|
123
|
+
// A route is "declared" when its owning module explicitly authored an I/O
|
|
124
|
+
// contract for it — via an `overrides` entry or a `bodyless` opt-in. Routes
|
|
125
|
+
// that were never declared must not be silently certified as `audited`
|
|
126
|
+
// (see the read-side default below).
|
|
127
|
+
const declared = bodylessRoute || Object.keys(override).length > 0;
|
|
128
|
+
const bodyRationale = parsed.method === 'DELETE'
|
|
129
|
+
? 'The resource identifier is supplied by the path; this route does not accept a request body.'
|
|
130
|
+
: 'This state-changing route does not accept a request body.';
|
|
131
|
+
let defaultBody = { policy: 'none', fields: [] };
|
|
132
|
+
if (stateChanging && !bodylessRoute) {
|
|
133
|
+
defaultBody = { policy: 'pending-audit', fields: [] };
|
|
134
|
+
} else if (stateChanging) {
|
|
135
|
+
defaultBody.rationale = bodyRationale;
|
|
136
|
+
}
|
|
137
|
+
// An undeclared read-side surface (query/headers) is *unknown*, not empty. A
|
|
138
|
+
// route whose owning module never authored any contract for it (no override,
|
|
139
|
+
// not bodyless) must not be silently certified as `audited` while asserting it
|
|
140
|
+
// consumes no query/header input — that would publish a false contract hiding
|
|
141
|
+
// live params (e.g. GET /api/work-items/<id>?source or the SSE ?tail cutoff).
|
|
142
|
+
// Declaring the route — via ANY override or a bodyless opt-in — is the explicit
|
|
143
|
+
// assertion that opts its unspecified read-side locations back into `none`.
|
|
144
|
+
const unspecifiedReadInput = location => (declared
|
|
145
|
+
? { policy: 'none', fields: [] }
|
|
146
|
+
: {
|
|
147
|
+
policy: 'pending-audit',
|
|
148
|
+
fields: [],
|
|
149
|
+
rationale: `The ${location} input surface for this route has not been audited.`,
|
|
150
|
+
});
|
|
151
|
+
const defaults = {
|
|
152
|
+
body: defaultBody,
|
|
153
|
+
path: routePathFields.length
|
|
154
|
+
? { policy: 'required', fields: routePathFields }
|
|
155
|
+
: { policy: 'none', fields: [] },
|
|
156
|
+
query: unspecifiedReadInput('query'),
|
|
157
|
+
headers: unspecifiedReadInput('headers'),
|
|
158
|
+
};
|
|
159
|
+
const request = {
|
|
160
|
+
body: normalizeLocation(override.body, defaults.body, parsed.key, 'body'),
|
|
161
|
+
path: normalizeLocation(override.path, defaults.path, parsed.key, 'path'),
|
|
162
|
+
query: normalizeLocation(override.query, defaults.query, parsed.key, 'query'),
|
|
163
|
+
headers: normalizeLocation(override.headers, defaults.headers, parsed.key, 'headers'),
|
|
164
|
+
aliases: cloneSerializable(override.aliases || []),
|
|
165
|
+
constraints: cloneSerializable(override.constraints || []),
|
|
166
|
+
};
|
|
167
|
+
if (!Array.isArray(request.aliases) || !Array.isArray(request.constraints)) {
|
|
168
|
+
throw new Error(`${parsed.key} aliases and constraints must be arrays`);
|
|
169
|
+
}
|
|
170
|
+
const noInput = ['body', 'path', 'query', 'headers']
|
|
171
|
+
.every(location => request[location].policy === 'none');
|
|
172
|
+
const hasPendingAudit = ['body', 'path', 'query', 'headers']
|
|
173
|
+
.some(location => request[location].policy === 'pending-audit');
|
|
174
|
+
// A route earns the `audited` badge only when its caller-supplied input surface
|
|
175
|
+
// has been EXPLICITLY described or affirmed — never merely by omission. Leaving
|
|
176
|
+
// body/query/headers unspecified defaults them to `none`, which previously
|
|
177
|
+
// auto-certified the whole route as `audited` and published a false "no input"
|
|
178
|
+
// contract for routes that actually read a query/body (e.g. GET
|
|
179
|
+
// /api/work-items/<id>?source, POST /api/projects/browse). Path fields are
|
|
180
|
+
// auto-derived from the template and do NOT count as an owner declaration.
|
|
181
|
+
// Explicit affirmation is any of: an owner override that declares a body/query/
|
|
182
|
+
// headers location, an explicit `noInput: true`, an explicit `audit` value, or
|
|
183
|
+
// (for state-changing routes) an explicit bodyless declaration whose rationale
|
|
184
|
+
// documents the empty contract. Otherwise the route stays `pending-audit`
|
|
185
|
+
// (review PR #966).
|
|
186
|
+
const describesInput = override.body !== undefined
|
|
187
|
+
|| override.query !== undefined
|
|
188
|
+
|| override.headers !== undefined
|
|
189
|
+
|| override.noInput === true;
|
|
190
|
+
const explicitlyAffirmed = describesInput
|
|
191
|
+
|| override.audit !== undefined
|
|
192
|
+
|| (stateChanging && bodylessRoute);
|
|
193
|
+
let audit;
|
|
194
|
+
if (override.audit) {
|
|
195
|
+
audit = override.audit;
|
|
196
|
+
} else if (hasPendingAudit || !explicitlyAffirmed) {
|
|
197
|
+
audit = 'pending-audit';
|
|
198
|
+
} else {
|
|
199
|
+
audit = 'audited';
|
|
200
|
+
}
|
|
201
|
+
if (!AUDIT_STATUSES.has(audit)) throw new Error(`${parsed.key} has invalid audit status "${audit}"`);
|
|
202
|
+
const negativeTests = override.negativeTests === undefined
|
|
203
|
+
? (stateChanging && request.body.policy !== 'none' ? [defaultPendingNegativeTest()] : [])
|
|
204
|
+
: cloneSerializable(override.negativeTests);
|
|
205
|
+
if (!Array.isArray(negativeTests)) throw new Error(`${parsed.key} negativeTests must be an array`);
|
|
206
|
+
const contract = {
|
|
207
|
+
...parsed,
|
|
208
|
+
owner,
|
|
209
|
+
audit,
|
|
210
|
+
stateChanging,
|
|
211
|
+
noInput,
|
|
212
|
+
request,
|
|
213
|
+
negativeTests,
|
|
214
|
+
};
|
|
215
|
+
if (noInput) {
|
|
216
|
+
contract.noInputReason = override.noInputReason
|
|
217
|
+
|| (stateChanging
|
|
218
|
+
? 'The action is selected by its fixed route and accepts no caller-supplied input.'
|
|
219
|
+
: 'This route consumes no caller-supplied body, path, query, or header input.');
|
|
220
|
+
}
|
|
221
|
+
if (stateChanging && request.body.policy === 'none' && !request.body.rationale) {
|
|
222
|
+
throw new Error(`${parsed.key} must explain why it accepts no request body`);
|
|
223
|
+
}
|
|
224
|
+
if (stateChanging && request.body.policy !== 'none' && negativeTests.length === 0) {
|
|
225
|
+
throw new Error(`${parsed.key} must declare a negative-test strategy`);
|
|
226
|
+
}
|
|
227
|
+
return contract;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectUniqueRouteKeys(owner, routes) {
|
|
231
|
+
const routeKeys = new Set();
|
|
232
|
+
for (const route of routes) {
|
|
233
|
+
const routeKey = canonicalRouteKey(route);
|
|
234
|
+
if (routeKeys.has(routeKey)) {
|
|
235
|
+
throw new Error(`${owner} lists duplicate route: ${routeKey}`);
|
|
236
|
+
}
|
|
237
|
+
routeKeys.add(routeKey);
|
|
238
|
+
}
|
|
239
|
+
return routeKeys;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildCatalog(ownerModules = OWNER_MODULES) {
|
|
243
|
+
const catalog = new Map();
|
|
244
|
+
for (const moduleContract of ownerModules) {
|
|
245
|
+
const owner = String(moduleContract.owner || '').trim();
|
|
246
|
+
if (!owner || !OWNERS.includes(owner)) throw new Error(`Invalid API contract owner "${owner}"`);
|
|
247
|
+
const routes = Array.isArray(moduleContract.routes) ? moduleContract.routes : [];
|
|
248
|
+
const bodyless = new Set((moduleContract.bodyless || []).map(canonicalRouteKey));
|
|
249
|
+
const overrides = moduleContract.overrides || {};
|
|
250
|
+
const normalizedOverrides = new Map(
|
|
251
|
+
Object.entries(overrides).map(([routeKey, override]) => [canonicalRouteKey(routeKey), override]),
|
|
252
|
+
);
|
|
253
|
+
// Detect same-owner duplicates in the normalized sequence BEFORE deduping.
|
|
254
|
+
// Collapsing the module's route list into a Set first would silently swallow
|
|
255
|
+
// a route listed twice by the same owner, so the cross-owner `catalog.has`
|
|
256
|
+
// guard below would never see it and the advertised one-to-one contract gate
|
|
257
|
+
// would only catch cross-owner collisions.
|
|
258
|
+
const routeKeys = new Set();
|
|
259
|
+
for (const routeKey of routes.map(canonicalRouteKey)) {
|
|
260
|
+
if (routeKeys.has(routeKey)) {
|
|
261
|
+
throw new Error(`${owner} lists duplicate API contract key: ${routeKey}`);
|
|
262
|
+
}
|
|
263
|
+
routeKeys.add(routeKey);
|
|
264
|
+
}
|
|
265
|
+
for (const bodylessKey of bodyless) {
|
|
266
|
+
if (!routeKeys.has(bodylessKey)) throw new Error(`${owner} bodyless route is not catalogued: ${bodylessKey}`);
|
|
267
|
+
}
|
|
268
|
+
for (const overrideKey of normalizedOverrides.keys()) {
|
|
269
|
+
if (!routeKeys.has(overrideKey)) {
|
|
270
|
+
throw new Error(`${owner} override route is not catalogued: ${overrideKey}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const routeKey of routeKeys) {
|
|
274
|
+
if (catalog.has(routeKey)) throw new Error(`Duplicate API contract key: ${routeKey}`);
|
|
275
|
+
const override = normalizedOverrides.get(routeKey) || {};
|
|
276
|
+
catalog.set(routeKey, buildRouteContract(owner, routeKey, bodyless, override));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return catalog;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ROUTE_CONTRACTS = buildCatalog();
|
|
283
|
+
|
|
284
|
+
function getRouteContract(route) {
|
|
285
|
+
const key = canonicalRouteKey(route);
|
|
286
|
+
return ROUTE_CONTRACTS.get(key) || null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function listRouteContracts() {
|
|
290
|
+
return [...ROUTE_CONTRACTS.values()].map(cloneSerializable);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function routeContractMetadata(route) {
|
|
294
|
+
const contract = getRouteContract(route);
|
|
295
|
+
if (!contract) throw new Error(`Missing route contract: ${canonicalRouteKey(route)}`);
|
|
296
|
+
return cloneSerializable({
|
|
297
|
+
key: contract.key,
|
|
298
|
+
owner: contract.owner,
|
|
299
|
+
audit: contract.audit,
|
|
300
|
+
noInput: contract.noInput,
|
|
301
|
+
...(contract.noInputReason ? { noInputReason: contract.noInputReason } : {}),
|
|
302
|
+
request: contract.request,
|
|
303
|
+
negativeTests: contract.negativeTests,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertRouteContractCoverage(routes, options = {}) {
|
|
308
|
+
if (!Array.isArray(routes)) throw new Error('Route registry must be an array');
|
|
309
|
+
const exact = options.exact !== false;
|
|
310
|
+
const requireAudited = options.requireAudited === true;
|
|
311
|
+
const seen = new Set();
|
|
312
|
+
for (const route of routes) {
|
|
313
|
+
const key = canonicalRouteKey(route);
|
|
314
|
+
if (seen.has(key)) throw new Error(`Duplicate route key in live registry: ${key}`);
|
|
315
|
+
seen.add(key);
|
|
316
|
+
if (!ROUTE_CONTRACTS.has(key)) throw new Error(`Missing route contract for live route: ${key}`);
|
|
317
|
+
}
|
|
318
|
+
if (exact) {
|
|
319
|
+
const extras = [...ROUTE_CONTRACTS.keys()].filter(key => !seen.has(key));
|
|
320
|
+
if (extras.length) throw new Error(`Contract catalog contains routes missing from live registry: ${extras.join(', ')}`);
|
|
321
|
+
}
|
|
322
|
+
if (requireAudited) {
|
|
323
|
+
for (const key of seen) assertRouteAuditClosure(ROUTE_CONTRACTS.get(key));
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Enforce audited-route closure for a single contract. A route is only allowed
|
|
329
|
+
// through the tightened gate when its caller-supplied input surface has been
|
|
330
|
+
// fully described:
|
|
331
|
+
// - no location may remain `pending-audit`;
|
|
332
|
+
// - every declared alias must document its canonical target;
|
|
333
|
+
// - a state-changing route must publish an explicit body policy plus a
|
|
334
|
+
// side-effect assertion (a negative-test strategy when it consumes input, or
|
|
335
|
+
// a bodyless rationale when it does not).
|
|
336
|
+
function assertRouteAuditClosure(contract) {
|
|
337
|
+
if (!contract) throw new Error('Cannot certify a missing route contract');
|
|
338
|
+
const key = contract.key;
|
|
339
|
+
if (contract.audit !== 'audited') {
|
|
340
|
+
throw new Error(`Route ${key} is still ${contract.audit}; every route must be audited before release`);
|
|
341
|
+
}
|
|
342
|
+
for (const location of ['body', 'path', 'query', 'headers']) {
|
|
343
|
+
const policy = contract.request[location].policy;
|
|
344
|
+
if (policy === 'pending-audit') {
|
|
345
|
+
throw new Error(`Route ${key} leaves its ${location} input surface pending-audit`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const alias of contract.request.aliases) {
|
|
349
|
+
if (!alias || typeof alias !== 'object'
|
|
350
|
+
|| !String(alias.name || '').trim()
|
|
351
|
+
|| !String(alias.canonical || '').trim()) {
|
|
352
|
+
throw new Error(`Route ${key} declares an undocumented alias (name and canonical are required)`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (contract.stateChanging) {
|
|
356
|
+
if (contract.request.body.policy === 'none') {
|
|
357
|
+
if (!contract.request.body.rationale) {
|
|
358
|
+
throw new Error(`Route ${key} is state-changing and must document its bodyless side-effect policy`);
|
|
359
|
+
}
|
|
360
|
+
} else if (contract.negativeTests.length === 0) {
|
|
361
|
+
throw new Error(`Route ${key} is state-changing and must declare a negative-test strategy`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Assert full audited-route closure across a live registry: coverage
|
|
368
|
+
// (one-to-one, no duplicates, no gaps) plus per-route audit certification.
|
|
369
|
+
function assertAuditedRouteClosure(routes, options = {}) {
|
|
370
|
+
return assertRouteContractCoverage(routes, { ...options, requireAudited: true });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Runtime-derived contract counts. Never cache — the gate and verification guide
|
|
374
|
+
// must reflect the live catalog so a regressed route is visible immediately.
|
|
375
|
+
function summarizeRouteContracts() {
|
|
376
|
+
const contracts = [...ROUTE_CONTRACTS.values()];
|
|
377
|
+
const byOwner = {};
|
|
378
|
+
for (const owner of OWNERS) byOwner[owner] = 0;
|
|
379
|
+
let audited = 0;
|
|
380
|
+
let pendingAudit = 0;
|
|
381
|
+
let stateChanging = 0;
|
|
382
|
+
let readOnly = 0;
|
|
383
|
+
let noInput = 0;
|
|
384
|
+
for (const contract of contracts) {
|
|
385
|
+
byOwner[contract.owner] = (byOwner[contract.owner] || 0) + 1;
|
|
386
|
+
if (contract.audit === 'audited') audited += 1; else pendingAudit += 1;
|
|
387
|
+
if (contract.stateChanging) stateChanging += 1; else readOnly += 1;
|
|
388
|
+
if (contract.noInput) noInput += 1;
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
total: contracts.length,
|
|
392
|
+
audited,
|
|
393
|
+
pendingAudit,
|
|
394
|
+
stateChanging,
|
|
395
|
+
readOnly,
|
|
396
|
+
noInput,
|
|
397
|
+
byOwner,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Derive the human-readable side-effect assertion for a contract. Read-only
|
|
402
|
+
// routes assert no mutation; state-changing routes assert their guard posture
|
|
403
|
+
// (bodyless rationale or the ordered validate-before-side-effect constraints).
|
|
404
|
+
function sideEffectAssertion(contract) {
|
|
405
|
+
if (!contract.stateChanging) {
|
|
406
|
+
return { mutates: false, assertion: 'Read-only; no persistent side effects.' };
|
|
407
|
+
}
|
|
408
|
+
if (contract.request.body.policy === 'none') {
|
|
409
|
+
return {
|
|
410
|
+
mutates: true,
|
|
411
|
+
assertion: contract.request.body.rationale
|
|
412
|
+
|| 'State-changing; accepts no request body.',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const guards = contract.request.constraints
|
|
416
|
+
.map(constraint => constraint.kind || constraint.rule)
|
|
417
|
+
.filter(Boolean);
|
|
418
|
+
const guardText = guards.length
|
|
419
|
+
? `Guarded by ${guards.length} constraint(s): ${guards.join(', ')}.`
|
|
420
|
+
: '';
|
|
421
|
+
return {
|
|
422
|
+
mutates: true,
|
|
423
|
+
assertion: `State-changing; rejects invalid input before side effects. ${guardText}`.trim(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Collect the "contract source" references a route derives its validation from:
|
|
428
|
+
// allowed-value sources, shape sources, and named constraint kinds. Static
|
|
429
|
+
// allowedValues arrays are surfaced separately so the matrix distinguishes a
|
|
430
|
+
// live source reference from a frozen enum.
|
|
431
|
+
function contractSources(contract) {
|
|
432
|
+
const sources = new Set();
|
|
433
|
+
for (const location of ['body', 'path', 'query', 'headers']) {
|
|
434
|
+
for (const field of contract.request[location].fields) {
|
|
435
|
+
if (field.allowedValuesSource) sources.add(field.allowedValuesSource);
|
|
436
|
+
if (field.shapeSource) sources.add(field.shapeSource);
|
|
437
|
+
if (field.defaultSource) sources.add(field.defaultSource);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const constraint of contract.request.constraints) {
|
|
441
|
+
if (constraint.kind) sources.add(constraint.kind);
|
|
442
|
+
else if (constraint.rule) sources.add(constraint.rule);
|
|
443
|
+
}
|
|
444
|
+
return [...sources];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Build the route-to-validation matrix from the live catalog. Every row records
|
|
448
|
+
// method/path, owner, accepted inputs, contract source, negative cases, status
|
|
449
|
+
// mapping, and the side-effect assertion — the shape consumed by the plan
|
|
450
|
+
// verification guide. Counts and rows derive at call time, never from a cache.
|
|
451
|
+
function buildRouteValidationMatrix(routes) {
|
|
452
|
+
if (routes !== undefined) assertAuditedRouteClosure(routes);
|
|
453
|
+
const rows = [...ROUTE_CONTRACTS.values()].map(contract => {
|
|
454
|
+
const acceptedInputs = {};
|
|
455
|
+
for (const location of ['body', 'path', 'query', 'headers']) {
|
|
456
|
+
const input = contract.request[location];
|
|
457
|
+
acceptedInputs[location] = {
|
|
458
|
+
policy: input.policy,
|
|
459
|
+
fields: input.fields.map(field => field.name),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
const negativeStatuses = [...new Set(
|
|
463
|
+
contract.negativeTests.map(test => test.expectedStatus).filter(Boolean),
|
|
464
|
+
)];
|
|
465
|
+
return {
|
|
466
|
+
key: contract.key,
|
|
467
|
+
method: contract.method,
|
|
468
|
+
path: contract.path,
|
|
469
|
+
owner: contract.owner,
|
|
470
|
+
audit: contract.audit,
|
|
471
|
+
acceptedInputs,
|
|
472
|
+
contractSource: contractSources(contract),
|
|
473
|
+
negativeCases: cloneSerializable(contract.negativeTests),
|
|
474
|
+
statusMapping: {
|
|
475
|
+
success: contract.stateChanging ? '2xx' : '200',
|
|
476
|
+
rejections: negativeStatuses,
|
|
477
|
+
},
|
|
478
|
+
sideEffect: sideEffectAssertion(contract),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
rows.sort((a, b) => a.key.localeCompare(b.key));
|
|
482
|
+
return {
|
|
483
|
+
generatedFrom: 'engine/api-contracts live catalog',
|
|
484
|
+
summary: summarizeRouteContracts(),
|
|
485
|
+
rows,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
module.exports = {
|
|
490
|
+
OWNERS,
|
|
491
|
+
canonicalRouteKey,
|
|
492
|
+
collectUniqueRouteKeys,
|
|
493
|
+
getRouteContract,
|
|
494
|
+
listRouteContracts,
|
|
495
|
+
routeContractMetadata,
|
|
496
|
+
assertRouteContractCoverage,
|
|
497
|
+
assertRouteAuditClosure,
|
|
498
|
+
assertAuditedRouteClosure,
|
|
499
|
+
summarizeRouteContracts,
|
|
500
|
+
buildRouteValidationMatrix,
|
|
501
|
+
// exported for testing — lets same-owner duplicate detection and default
|
|
502
|
+
// certification be exercised against synthetic ownership modules. Both names
|
|
503
|
+
// resolve to the same builder (`_buildCatalog` retained for existing callers).
|
|
504
|
+
buildCatalog,
|
|
505
|
+
_buildCatalog: buildCatalog,
|
|
506
|
+
};
|