@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.3
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-mempalace-curator.md +47 -0
- package/bin/install.js +25 -7
- package/commands/gsd/mempalace-capture.md +71 -0
- package/commands/gsd/mempalace-recall.md +102 -0
- package/commands/gsd/ns-context.md +4 -2
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +110 -92
- package/gsd-core/bin/lib/capability-activation.cjs +86 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
- package/gsd-core/bin/lib/capability-state.cjs +31 -18
- package/gsd-core/bin/lib/check-command-router.cjs +328 -1
- package/gsd-core/bin/lib/clusters.cjs +2 -0
- package/gsd-core/bin/lib/command-roster.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +33 -10
- package/gsd-core/bin/lib/config-loader.cjs +7 -8
- package/gsd-core/bin/lib/config-schema.cjs +32 -3
- package/gsd-core/bin/lib/config.cjs +1 -2
- package/gsd-core/bin/lib/core.cjs +5 -2
- package/gsd-core/bin/lib/init.cjs +8 -5
- package/gsd-core/bin/lib/install-profiles.cjs +2 -1
- package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
- package/gsd-core/bin/lib/model-resolver.cjs +47 -5
- package/gsd-core/bin/lib/phase.cjs +71 -11
- package/gsd-core/bin/lib/profile-output.cjs +45 -4
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
- package/gsd-core/bin/lib/roadmap.cjs +97 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +20 -28
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
- package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
- package/gsd-core/bin/lib/state-command-router.cjs +15 -3
- package/gsd-core/bin/lib/state-document.cjs +46 -1
- package/gsd-core/bin/lib/state.cjs +221 -35
- package/gsd-core/bin/lib/verify.cjs +19 -4
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
- package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
- package/gsd-core/bin/shared/model-catalog.json +1 -0
- package/gsd-core/references/loop-hook-dispatch.md +61 -0
- package/gsd-core/templates/config.json +1 -1
- package/gsd-core/workflows/audit-milestone.md +5 -3
- package/gsd-core/workflows/autonomous.md +10 -5
- package/gsd-core/workflows/code-review-fix.md +9 -7
- package/gsd-core/workflows/code-review.md +8 -6
- package/gsd-core/workflows/discuss-phase.md +14 -0
- package/gsd-core/workflows/execute-phase.md +82 -143
- package/gsd-core/workflows/help/modes/full.md +3 -1
- package/gsd-core/workflows/new-project.md +3 -3
- package/gsd-core/workflows/plan-phase.md +89 -221
- package/gsd-core/workflows/profile-user.md +3 -1
- package/gsd-core/workflows/progress.md +58 -1
- package/gsd-core/workflows/quick.md +9 -6
- package/gsd-core/workflows/review.md +19 -2
- package/gsd-core/workflows/secure-phase.md +4 -2
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/ship.md +57 -0
- package/gsd-core/workflows/validate-phase.md +4 -2
- package/gsd-core/workflows/verify-work.md +27 -11
- package/package.json +3 -2
- package/scripts/affected-tests-lib.cjs +16 -4
- package/scripts/gen-capability-registry.cjs +359 -48
- package/scripts/gen-inventory-manifest.cjs +1 -4
- package/scripts/gen-loop-host-contract.cjs +55 -0
- package/scripts/issue-version-gate.cjs +140 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
- package/scripts/lint-allow-test-rule-refs.cjs +162 -0
- package/scripts/lint-test-file-count.allowlist.json +8 -0
- package/scripts/release-tarball-smoke.cjs +7 -1
- package/scripts/run-tests.cjs +74 -14
|
@@ -34,6 +34,9 @@ const SCHEMA_VERSION = '1';
|
|
|
34
34
|
// registry generator and the loop-host-contract generator share one source of truth.
|
|
35
35
|
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
|
|
36
36
|
|
|
37
|
+
// Wired-points helper — tells us which points actually have render-hooks call sites.
|
|
38
|
+
const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs');
|
|
39
|
+
|
|
37
40
|
// Canonical point order — explicit constant (do NOT rely on Set insertion order).
|
|
38
41
|
// Used for point-ordering semantics in consumes-satisfiability validation and topo-sort.
|
|
39
42
|
const POINT_ORDER = [
|
|
@@ -93,16 +96,42 @@ for (const entry of LOOP_HOST_CONTRACT) {
|
|
|
93
96
|
* Loads the set of keys from the central config-schema manifest.
|
|
94
97
|
* Returns a Set<string>. Used for collision detection.
|
|
95
98
|
*
|
|
96
|
-
*
|
|
97
|
-
* (
|
|
99
|
+
* Contract:
|
|
100
|
+
* - ENOENT (file not found): returns empty Set silently — legitimate absent case.
|
|
101
|
+
* - Any other read error OR JSON parse error: writes a prominent warning to stderr
|
|
102
|
+
* naming the schema path and the underlying error, then throws ExitError(1).
|
|
103
|
+
* A parse error clearly states the schema is broken (not merely absent).
|
|
104
|
+
*
|
|
105
|
+
* @param {string} [schemaPath] Path to the config-schema manifest. Defaults to
|
|
106
|
+
* CONFIG_SCHEMA_PATH (the real production path).
|
|
107
|
+
* Overridable for unit testing with fixture paths.
|
|
108
|
+
* @returns {Set<string>}
|
|
98
109
|
*/
|
|
99
|
-
function loadCentralConfigKeys() {
|
|
110
|
+
function loadCentralConfigKeys(schemaPath = CONFIG_SCHEMA_PATH) {
|
|
111
|
+
let raw;
|
|
100
112
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
raw = fs.readFileSync(schemaPath, 'utf8');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code === 'ENOENT') {
|
|
116
|
+
return new Set();
|
|
117
|
+
}
|
|
118
|
+
process.stderr.write(
|
|
119
|
+
' ERROR Failed to read config-schema manifest at ' + schemaPath + ': ' + err.message + '\n',
|
|
120
|
+
);
|
|
121
|
+
throw new ExitError(1, 'could not read config-schema manifest');
|
|
105
122
|
}
|
|
123
|
+
|
|
124
|
+
let manifest;
|
|
125
|
+
try {
|
|
126
|
+
manifest = JSON.parse(raw);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
process.stderr.write(
|
|
129
|
+
' ERROR Config-schema manifest at ' + schemaPath + ' is broken (JSON parse error): ' + err.message + '\n',
|
|
130
|
+
);
|
|
131
|
+
throw new ExitError(1, 'config-schema manifest JSON is malformed');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
|
|
106
135
|
}
|
|
107
136
|
|
|
108
137
|
// ─── Config-slice validation ──────────────────────────────────────────────────
|
|
@@ -205,6 +234,7 @@ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
|
|
205
234
|
const VALID_ROLES = new Set(['feature', 'runtime']);
|
|
206
235
|
const VALID_TIERS = new Set(['core', 'standard', 'full']);
|
|
207
236
|
const VALID_ON_ERROR = new Set(['skip', 'halt']);
|
|
237
|
+
const RUNTIME_COMPAT_WILDCARD = '*';
|
|
208
238
|
|
|
209
239
|
/**
|
|
210
240
|
* Validate a single capability declaration.
|
|
@@ -337,9 +367,74 @@ function validateCommandEntry(capId, entry, prefix) {
|
|
|
337
367
|
return errors;
|
|
338
368
|
}
|
|
339
369
|
|
|
370
|
+
function validateRuntimeCompat(capId, runtimeCompat) {
|
|
371
|
+
const errors = [];
|
|
372
|
+
const ctx = 'capability "' + capId + '" runtimeCompat';
|
|
373
|
+
|
|
374
|
+
if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) {
|
|
375
|
+
errors.push(ctx + ' must be an object with supported and unsupported arrays');
|
|
376
|
+
return errors;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const validateRuntimeArray = (field, { allowWildcard }) => {
|
|
380
|
+
const value = runtimeCompat[field];
|
|
381
|
+
if (!Array.isArray(value)) {
|
|
382
|
+
errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : ''));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (field === 'supported' && value.length === 0) {
|
|
386
|
+
errors.push(ctx + '.supported must be a non-empty array');
|
|
387
|
+
}
|
|
388
|
+
let hasWildcard = false;
|
|
389
|
+
for (let i = 0; i < value.length; i++) {
|
|
390
|
+
const entry = value[i];
|
|
391
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
392
|
+
errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string');
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') {
|
|
396
|
+
errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name');
|
|
397
|
+
}
|
|
398
|
+
if (entry === RUNTIME_COMPAT_WILDCARD) {
|
|
399
|
+
if (!allowWildcard) {
|
|
400
|
+
errors.push(ctx + '.' + field + ' must not include wildcard "*"');
|
|
401
|
+
}
|
|
402
|
+
hasWildcard = true;
|
|
403
|
+
} else if (!KEBAB_RE.test(entry)) {
|
|
404
|
+
errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (hasWildcard && value.length > 1) {
|
|
408
|
+
errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids');
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
validateRuntimeArray('supported', { allowWildcard: true });
|
|
413
|
+
validateRuntimeArray('unsupported', { allowWildcard: false });
|
|
414
|
+
|
|
415
|
+
if (runtimeCompat.notes !== undefined) {
|
|
416
|
+
if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) {
|
|
417
|
+
errors.push(ctx + '.notes must be an object of runtime id to string if present');
|
|
418
|
+
} else {
|
|
419
|
+
for (const [key, value] of Object.entries(runtimeCompat.notes)) {
|
|
420
|
+
if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) {
|
|
421
|
+
errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"');
|
|
422
|
+
}
|
|
423
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
424
|
+
errors.push(ctx + '.notes["' + key + '"] must be a non-empty string');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return errors;
|
|
431
|
+
}
|
|
432
|
+
|
|
340
433
|
function validateFeatureBody(cap) {
|
|
341
434
|
const errors = [];
|
|
342
435
|
|
|
436
|
+
errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat));
|
|
437
|
+
|
|
343
438
|
if (!Array.isArray(cap.skills)) {
|
|
344
439
|
errors.push('skills must be an array of strings');
|
|
345
440
|
} else {
|
|
@@ -847,6 +942,78 @@ function validateRuntimeBody(cap) {
|
|
|
847
942
|
return errors;
|
|
848
943
|
}
|
|
849
944
|
|
|
945
|
+
function materializeHookFragments(cap, capDir) {
|
|
946
|
+
const errors = [];
|
|
947
|
+
const hookGroups = [
|
|
948
|
+
['steps', Array.isArray(cap.steps) ? cap.steps : []],
|
|
949
|
+
['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
|
|
950
|
+
];
|
|
951
|
+
|
|
952
|
+
for (const [groupName, hooks] of hookGroups) {
|
|
953
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
954
|
+
const hook = hooks[i];
|
|
955
|
+
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue;
|
|
956
|
+
const fragment = hook.fragment;
|
|
957
|
+
if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue;
|
|
958
|
+
if (typeof fragment.inline === 'string') continue;
|
|
959
|
+
if (typeof fragment.path !== 'string') continue;
|
|
960
|
+
|
|
961
|
+
const abs = path.resolve(capDir, fragment.path);
|
|
962
|
+
const capRoot = path.resolve(capDir);
|
|
963
|
+
if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) {
|
|
964
|
+
errors.push(
|
|
965
|
+
cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' +
|
|
966
|
+
fragment.path,
|
|
967
|
+
);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
try {
|
|
972
|
+
fragment.inline = fs.readFileSync(abs, 'utf8');
|
|
973
|
+
} catch (err) {
|
|
974
|
+
errors.push(
|
|
975
|
+
cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
|
|
976
|
+
fragment.path + ' (' + err.message + ')',
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return errors;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function validateFragment(fragment, prefix) {
|
|
986
|
+
const errors = [];
|
|
987
|
+
|
|
988
|
+
if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) {
|
|
989
|
+
errors.push(prefix + ' must be an object with path or inline key');
|
|
990
|
+
return errors;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path');
|
|
994
|
+
const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline');
|
|
995
|
+
if (!hasPath && !hasInline) {
|
|
996
|
+
errors.push(prefix + ' must have a "path" or "inline" key');
|
|
997
|
+
}
|
|
998
|
+
if (hasInline) {
|
|
999
|
+
const inline = fragment.inline;
|
|
1000
|
+
if (typeof inline !== 'string') {
|
|
1001
|
+
errors.push(prefix + '.inline must be a string');
|
|
1002
|
+
} else if (inline === '') {
|
|
1003
|
+
errors.push(prefix + '.inline must be a non-empty string');
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// S1: fragment.path traversal guard — must be a relative path with no ".." segments
|
|
1007
|
+
if (hasPath) {
|
|
1008
|
+
const p = fragment.path;
|
|
1009
|
+
if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
|
|
1010
|
+
errors.push(prefix + '.path must be a relative path with no ".." segments');
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return errors;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
850
1017
|
/**
|
|
851
1018
|
* Validate a single step entry.
|
|
852
1019
|
*
|
|
@@ -866,15 +1033,17 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
|
|
|
866
1033
|
}
|
|
867
1034
|
|
|
868
1035
|
if (typeof step.ref !== 'object' || step.ref === null) {
|
|
869
|
-
errors.push(prefix + '.ref must be an object with skill or
|
|
1036
|
+
errors.push(prefix + '.ref must be an object with skill, agent, or command key');
|
|
870
1037
|
} else {
|
|
871
1038
|
const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
|
|
872
1039
|
const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1040
|
+
const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
|
|
1041
|
+
const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
|
|
1042
|
+
if (dispatchCount === 0) {
|
|
1043
|
+
errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
|
|
1044
|
+
} else if (dispatchCount > 1) {
|
|
1045
|
+
// ref must be exclusive: skill XOR agent XOR command
|
|
1046
|
+
errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
|
|
878
1047
|
}
|
|
879
1048
|
if (hasSkill && typeof step.ref.skill !== 'string') {
|
|
880
1049
|
errors.push(prefix + '.ref.skill must be a string');
|
|
@@ -904,6 +1073,9 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
|
|
|
904
1073
|
[...declaredAgents].join(', ') + ']',
|
|
905
1074
|
);
|
|
906
1075
|
}
|
|
1076
|
+
if (hasCommand && typeof step.ref.command !== 'string') {
|
|
1077
|
+
errors.push(prefix + '.ref.command must be a string');
|
|
1078
|
+
}
|
|
907
1079
|
}
|
|
908
1080
|
|
|
909
1081
|
if (!Array.isArray(step.produces)) {
|
|
@@ -926,6 +1098,10 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
|
|
|
926
1098
|
errors.push(prefix + '.when must be a string if present');
|
|
927
1099
|
}
|
|
928
1100
|
|
|
1101
|
+
if (step.fragment !== undefined) {
|
|
1102
|
+
errors.push(...validateFragment(step.fragment, prefix + '.fragment'));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
929
1105
|
if (!VALID_ON_ERROR.has(step.onError)) {
|
|
930
1106
|
errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')');
|
|
931
1107
|
}
|
|
@@ -944,23 +1120,24 @@ function validateContribution(contrib, prefix) {
|
|
|
944
1120
|
errors.push(prefix + '.into must be a string (agent role name)');
|
|
945
1121
|
}
|
|
946
1122
|
|
|
947
|
-
if (
|
|
948
|
-
errors.push(prefix + '.
|
|
1123
|
+
if (!Array.isArray(contrib.produces)) {
|
|
1124
|
+
errors.push(prefix + '.produces must be an array');
|
|
949
1125
|
} else {
|
|
950
|
-
const
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
}
|
|
1126
|
+
for (const p of contrib.produces) {
|
|
1127
|
+
if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (!Array.isArray(contrib.consumes)) {
|
|
1132
|
+
errors.push(prefix + '.consumes must be an array');
|
|
1133
|
+
} else {
|
|
1134
|
+
for (const c of contrib.consumes) {
|
|
1135
|
+
if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
|
|
961
1136
|
}
|
|
962
1137
|
}
|
|
963
1138
|
|
|
1139
|
+
errors.push(...validateFragment(contrib.fragment, prefix + '.fragment'));
|
|
1140
|
+
|
|
964
1141
|
if (contrib.when !== undefined && typeof contrib.when !== 'string') {
|
|
965
1142
|
errors.push(prefix + '.when must be a string if present');
|
|
966
1143
|
}
|
|
@@ -1146,8 +1323,38 @@ function validateConsumesGlobal(capMap) {
|
|
|
1146
1323
|
}
|
|
1147
1324
|
}
|
|
1148
1325
|
|
|
1149
|
-
//
|
|
1150
|
-
// at the same
|
|
1326
|
+
// Duplicate-producer invariant: two capability steps may not produce the same artifact
|
|
1327
|
+
// at the same Loop Extension Point. Same-point dual production makes data-flow resolution
|
|
1328
|
+
// ambiguous and is rejected at gen time (Decision #6).
|
|
1329
|
+
for (const artifact of Object.keys(capHookProducers)) {
|
|
1330
|
+
if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
|
|
1331
|
+
const producers = capHookProducers[artifact];
|
|
1332
|
+
// Group by pointIdx
|
|
1333
|
+
const byPoint = Object.create(null);
|
|
1334
|
+
for (const entry of producers) {
|
|
1335
|
+
if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = [];
|
|
1336
|
+
byPoint[entry.pointIdx].push(entry);
|
|
1337
|
+
}
|
|
1338
|
+
for (const pointIdxStr of Object.keys(byPoint)) {
|
|
1339
|
+
const group = byPoint[pointIdxStr];
|
|
1340
|
+
// Count distinct (capId, stepIdx) producer steps — a single step listing the same
|
|
1341
|
+
// artifact twice in its produces array pushes duplicate entries but represents only
|
|
1342
|
+
// ONE producer step and must not false-positive the cross-step gate.
|
|
1343
|
+
const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx));
|
|
1344
|
+
if (distinctProducers.size >= 2) {
|
|
1345
|
+
const pointIdx = Number(pointIdxStr);
|
|
1346
|
+
const pointName = POINT_ORDER[pointIdx];
|
|
1347
|
+
const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', ');
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' +
|
|
1350
|
+
'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' +
|
|
1351
|
+
'(capabilities: ' + capIds + '). ' +
|
|
1352
|
+
'Two capability steps producing the same artifact at the same Loop Extension Point ' +
|
|
1353
|
+
'makes data-flow resolution ambiguous and is rejected at gen time.',
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1151
1358
|
|
|
1152
1359
|
// Now check every hook step's consumes.
|
|
1153
1360
|
// Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A].
|
|
@@ -1297,6 +1504,39 @@ function validateCrossCapability(capMap, centralKeys) {
|
|
|
1297
1504
|
}
|
|
1298
1505
|
}
|
|
1299
1506
|
|
|
1507
|
+
// runtimeCompat: explicit runtime ids must reference runtime capabilities.
|
|
1508
|
+
// The wildcard "*" means descriptor-backed runtimes are supported by default.
|
|
1509
|
+
const runtimeIds = new Set();
|
|
1510
|
+
for (const [id, cap] of capMap) {
|
|
1511
|
+
if (cap.role === 'runtime') runtimeIds.add(id);
|
|
1512
|
+
}
|
|
1513
|
+
for (const [capId, cap] of capMap) {
|
|
1514
|
+
if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue;
|
|
1515
|
+
for (const field of ['supported', 'unsupported']) {
|
|
1516
|
+
const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : [];
|
|
1517
|
+
for (const runtimeId of entries) {
|
|
1518
|
+
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
|
|
1519
|
+
if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue;
|
|
1520
|
+
if (!runtimeIds.has(runtimeId)) {
|
|
1521
|
+
errors.push(
|
|
1522
|
+
'capability "' + capId + '" runtimeCompat.' + field +
|
|
1523
|
+
' references unknown runtime "' + runtimeId + '"',
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') {
|
|
1529
|
+
for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) {
|
|
1530
|
+
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
|
|
1531
|
+
if (!runtimeIds.has(runtimeId)) {
|
|
1532
|
+
errors.push(
|
|
1533
|
+
'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"',
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1300
1540
|
// requires: acyclic
|
|
1301
1541
|
const cycleErrors = detectRequiresCycles(capMap);
|
|
1302
1542
|
errors.push(...cycleErrors);
|
|
@@ -1384,14 +1624,7 @@ function computeRequiresClosure(id, capMap) {
|
|
|
1384
1624
|
|
|
1385
1625
|
// ─── Topological ordering ─────────────────────────────────────────────────────
|
|
1386
1626
|
|
|
1387
|
-
|
|
1388
|
-
* Topologically sort steps at a given point by produces/consumes.
|
|
1389
|
-
* Capability-id tiebreak for determinism.
|
|
1390
|
-
*
|
|
1391
|
-
* @param {{ capId: string, step: object }[]} entries
|
|
1392
|
-
* @returns {{ capId: string, step: object }[]}
|
|
1393
|
-
*/
|
|
1394
|
-
function topoSortSteps(entries) {
|
|
1627
|
+
function topoSortHookEntries(entries, hookKey, hookKind) {
|
|
1395
1628
|
if (entries.length <= 1) return entries;
|
|
1396
1629
|
|
|
1397
1630
|
// Build adjacency: entry A must come before entry B if B consumes something A produces
|
|
@@ -1400,10 +1633,10 @@ function topoSortSteps(entries) {
|
|
|
1400
1633
|
const adj = Array.from({ length: n }, () => []);
|
|
1401
1634
|
|
|
1402
1635
|
for (let i = 0; i < n; i++) {
|
|
1403
|
-
const producesI = new Set(entries[i].
|
|
1636
|
+
const producesI = new Set(entries[i][hookKey].produces || []);
|
|
1404
1637
|
for (let j = 0; j < n; j++) {
|
|
1405
1638
|
if (i === j) continue;
|
|
1406
|
-
const consumesJ = entries[j].
|
|
1639
|
+
const consumesJ = entries[j][hookKey].consumes || [];
|
|
1407
1640
|
for (const artifact of consumesJ) {
|
|
1408
1641
|
if (producesI.has(artifact)) {
|
|
1409
1642
|
adj[i].push(j);
|
|
@@ -1441,8 +1674,8 @@ function topoSortSteps(entries) {
|
|
|
1441
1674
|
if (result.length < n) {
|
|
1442
1675
|
const sortedIds = entries.map((e) => e.capId).join(', ');
|
|
1443
1676
|
throw new Error(
|
|
1444
|
-
'produces/consumes cycle detected in
|
|
1445
|
-
(entries[0] && entries[0]
|
|
1677
|
+
'produces/consumes cycle detected in ' + hookKind + ' at point "' +
|
|
1678
|
+
(entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') +
|
|
1446
1679
|
'" among capabilities [' + sortedIds + ']: ' +
|
|
1447
1680
|
'a cycle in hook produces/consumes prevents deterministic ordering',
|
|
1448
1681
|
);
|
|
@@ -1450,6 +1683,21 @@ function topoSortSteps(entries) {
|
|
|
1450
1683
|
return result;
|
|
1451
1684
|
}
|
|
1452
1685
|
|
|
1686
|
+
/**
|
|
1687
|
+
* Topologically sort steps at a given point by produces/consumes.
|
|
1688
|
+
* Capability-id tiebreak for determinism.
|
|
1689
|
+
*
|
|
1690
|
+
* @param {{ capId: string, step: object }[]} entries
|
|
1691
|
+
* @returns {{ capId: string, step: object }[]}
|
|
1692
|
+
*/
|
|
1693
|
+
function topoSortSteps(entries) {
|
|
1694
|
+
return topoSortHookEntries(entries, 'step', 'steps');
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function topoSortContributions(entries) {
|
|
1698
|
+
return topoSortHookEntries(entries, 'contrib', 'contributions');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1453
1701
|
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
|
|
1454
1702
|
|
|
1455
1703
|
// FIX 5 (lazy requires): paths are declared at top level but the actual require()
|
|
@@ -1716,6 +1964,54 @@ function runConfigFormatParityGate(capMap) {
|
|
|
1716
1964
|
}
|
|
1717
1965
|
}
|
|
1718
1966
|
|
|
1967
|
+
// ─── Gen-time wired guard ─────────────────────────────────────────────────────
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* Validate that every hook point declared by a capability has a corresponding
|
|
1971
|
+
* `loop render-hooks <point>` call site in one of the host-loop workflow files.
|
|
1972
|
+
*
|
|
1973
|
+
* Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points
|
|
1974
|
+
* are already caught by validateStep/validateContribution/validateGate — do not
|
|
1975
|
+
* double-report.
|
|
1976
|
+
*
|
|
1977
|
+
* @param {object} cap Validated capability object.
|
|
1978
|
+
* @param {Set<string>} wiredSet Set of points that have call sites in host workflows.
|
|
1979
|
+
* @returns {string[]} Array of error strings; empty means all points are wired.
|
|
1980
|
+
*/
|
|
1981
|
+
function validateHooksWired(cap, wiredSet) {
|
|
1982
|
+
const errors = [];
|
|
1983
|
+
const capId = cap.id || '(unknown)';
|
|
1984
|
+
|
|
1985
|
+
function checkPoint(point, groupName, idx) {
|
|
1986
|
+
// Only flag valid points that are unwired — invalid points are schema-validator's job.
|
|
1987
|
+
if (!VALID_LOOP_POINTS.has(point)) return;
|
|
1988
|
+
if (!wiredSet.has(point)) {
|
|
1989
|
+
errors.push(
|
|
1990
|
+
'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point +
|
|
1991
|
+
'" is declared but not wired in any host-loop workflow ' +
|
|
1992
|
+
'(no `loop render-hooks ' + point + '` call site). ' +
|
|
1993
|
+
'Wire the call site in the host workflow ' +
|
|
1994
|
+
'(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.',
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
for (let i = 0; i < (cap.steps || []).length; i++) {
|
|
2000
|
+
const hook = cap.steps[i];
|
|
2001
|
+
if (hook.point !== undefined) checkPoint(hook.point, 'steps', i);
|
|
2002
|
+
}
|
|
2003
|
+
for (let i = 0; i < (cap.contributions || []).length; i++) {
|
|
2004
|
+
const hook = cap.contributions[i];
|
|
2005
|
+
if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i);
|
|
2006
|
+
}
|
|
2007
|
+
for (let i = 0; i < (cap.gates || []).length; i++) {
|
|
2008
|
+
const hook = cap.gates[i];
|
|
2009
|
+
if (hook.point !== undefined) checkPoint(hook.point, 'gates', i);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
return errors;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
1719
2015
|
// ─── Registry builder ─────────────────────────────────────────────────────────
|
|
1720
2016
|
|
|
1721
2017
|
/**
|
|
@@ -1737,6 +2033,10 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
|
|
|
1737
2033
|
return { capMap, errors };
|
|
1738
2034
|
}
|
|
1739
2035
|
|
|
2036
|
+
// Compute wired points ONCE before iterating capabilities so the filesystem
|
|
2037
|
+
// scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
|
|
2038
|
+
const wiredSet = getWiredLoopPoints(ROOT);
|
|
2039
|
+
|
|
1740
2040
|
const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
|
|
1741
2041
|
.filter((e) => e.isDirectory())
|
|
1742
2042
|
.map((e) => e.name)
|
|
@@ -1768,6 +2068,19 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
|
|
|
1768
2068
|
continue;
|
|
1769
2069
|
}
|
|
1770
2070
|
|
|
2071
|
+
// Gen-time wired guard: reject hooks that declare a valid point with no call site.
|
|
2072
|
+
const wiredErrors = validateHooksWired(cap, wiredSet);
|
|
2073
|
+
if (wiredErrors.length > 0) {
|
|
2074
|
+
for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
|
|
2079
|
+
if (fragmentErrors.length > 0) {
|
|
2080
|
+
for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1771
2084
|
capMap.set(cap.id, cap);
|
|
1772
2085
|
}
|
|
1773
2086
|
|
|
@@ -1892,14 +2205,9 @@ function buildRegistry(capMap) {
|
|
|
1892
2205
|
...e.step,
|
|
1893
2206
|
}));
|
|
1894
2207
|
|
|
1895
|
-
// Contributions:
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
const intoCompare = a.contrib.into.localeCompare(b.contrib.into);
|
|
1899
|
-
if (intoCompare !== 0) return intoCompare;
|
|
1900
|
-
return a.capId.localeCompare(b.capId);
|
|
1901
|
-
});
|
|
1902
|
-
byLoopPoint[point].contributions = contribs.map((e) => ({
|
|
2208
|
+
// Contributions: topological sort by produces/consumes, cap-id tiebreak
|
|
2209
|
+
const sortedContribs = topoSortContributions(pointContribs.get(point));
|
|
2210
|
+
byLoopPoint[point].contributions = sortedContribs.map((e) => ({
|
|
1903
2211
|
capId: e.capId,
|
|
1904
2212
|
...e.contrib,
|
|
1905
2213
|
}));
|
|
@@ -2238,6 +2546,7 @@ module.exports = {
|
|
|
2238
2546
|
validateConsumesGlobal,
|
|
2239
2547
|
validateCrossCapability,
|
|
2240
2548
|
classifyCrossErrors,
|
|
2549
|
+
loadCentralConfigKeys,
|
|
2241
2550
|
loadAndValidate,
|
|
2242
2551
|
buildRegistry,
|
|
2243
2552
|
serializeRegistry,
|
|
@@ -2252,12 +2561,14 @@ module.exports = {
|
|
|
2252
2561
|
POINT_TO_CONTRACT,
|
|
2253
2562
|
HOST_ARTIFACT_EARLIEST_POINT_IDX,
|
|
2254
2563
|
SCHEMA_VERSION,
|
|
2564
|
+
validateHooksWired,
|
|
2255
2565
|
// ADR-857 phase 4a: derived views + gates
|
|
2256
2566
|
deriveCapabilityClusters,
|
|
2257
2567
|
deriveProfileMembership,
|
|
2258
2568
|
runConsistencyGate,
|
|
2259
2569
|
// ADR-959: command entry validation
|
|
2260
2570
|
validateCommandEntry,
|
|
2571
|
+
validateRuntimeCompat,
|
|
2261
2572
|
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
|
|
2262
2573
|
validateConfigHome,
|
|
2263
2574
|
validateArtifactLayout,
|
|
@@ -61,7 +61,7 @@ const FAMILIES = [
|
|
|
61
61
|
];
|
|
62
62
|
|
|
63
63
|
function buildManifest() {
|
|
64
|
-
const manifest = {
|
|
64
|
+
const manifest = { families: {} };
|
|
65
65
|
for (const { name, dir, filter, toName } of FAMILIES) {
|
|
66
66
|
manifest.families[name] = fs
|
|
67
67
|
.readdirSync(dir)
|
|
@@ -78,9 +78,6 @@ function main() {
|
|
|
78
78
|
if (flag === '--check') {
|
|
79
79
|
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
|
80
80
|
const live = buildManifest();
|
|
81
|
-
// Strip the generated date for comparison
|
|
82
|
-
delete committed.generated;
|
|
83
|
-
delete live.generated;
|
|
84
81
|
const committedStr = JSON.stringify(committed, null, 2);
|
|
85
82
|
const liveStr = JSON.stringify(live, null, 2);
|
|
86
83
|
if (committedStr !== liveStr) {
|
|
@@ -449,6 +449,58 @@ function main() {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
+
// ─── Derived single-source-of-truth exports ───────────────────────────────────
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Repo-relative paths to every host-loop workflow file, derived from STEP_WORKFLOWS.
|
|
456
|
+
* This is the ONLY canonical enumeration of host-loop files — all consumers (tests,
|
|
457
|
+
* registry generator, conformance gate) must derive from this rather than maintaining
|
|
458
|
+
* a separate hardcoded list.
|
|
459
|
+
*/
|
|
460
|
+
const HOST_LOOP_FILES = STEP_WORKFLOWS.map((w) => 'gsd-core/workflows/' + w.file);
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Pure function: scan a text string for `loop render-hooks <point>` call sites.
|
|
464
|
+
* Returns a Set of matched point strings.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} text Content of a workflow file (or any text).
|
|
467
|
+
* @returns {Set<string>}
|
|
468
|
+
*/
|
|
469
|
+
function scanWiredPoints(text) {
|
|
470
|
+
const re = /loop render-hooks\s+([a-z:]+)/g;
|
|
471
|
+
const result = new Set();
|
|
472
|
+
let m;
|
|
473
|
+
while ((m = re.exec(text)) !== null) {
|
|
474
|
+
result.add(m[1]);
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read every host-loop workflow file and return the union of all wired loop points
|
|
481
|
+
* (i.e. points that have a `loop render-hooks <point>` call site).
|
|
482
|
+
*
|
|
483
|
+
* @param {string} [repoRoot] Path to the repository root. Defaults to ROOT.
|
|
484
|
+
* @returns {Set<string>}
|
|
485
|
+
*/
|
|
486
|
+
function getWiredLoopPoints(repoRoot) {
|
|
487
|
+
const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT;
|
|
488
|
+
const result = new Set();
|
|
489
|
+
for (const relPath of HOST_LOOP_FILES) {
|
|
490
|
+
const absPath = path.join(resolvedRoot, relPath);
|
|
491
|
+
let content;
|
|
492
|
+
try {
|
|
493
|
+
content = fs.readFileSync(absPath, 'utf8');
|
|
494
|
+
} catch (err) {
|
|
495
|
+
throw new Error('getWiredLoopPoints: cannot read host-loop file ' + absPath + ': ' + err.message);
|
|
496
|
+
}
|
|
497
|
+
for (const point of scanWiredPoints(content)) {
|
|
498
|
+
result.add(point);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
|
|
452
504
|
// ─── Exports (for tests) ─────────────────────────────────────────────────────
|
|
453
505
|
|
|
454
506
|
module.exports = {
|
|
@@ -459,9 +511,12 @@ module.exports = {
|
|
|
459
511
|
serializeContract,
|
|
460
512
|
normalizeLineEndings,
|
|
461
513
|
STEP_WORKFLOWS,
|
|
514
|
+
HOST_LOOP_FILES,
|
|
462
515
|
CANONICAL_POINTS,
|
|
463
516
|
EXPECTED_POINTS_BY_STEP,
|
|
464
517
|
ROLE_TO_AGENT,
|
|
518
|
+
scanWiredPoints,
|
|
519
|
+
getWiredLoopPoints,
|
|
465
520
|
};
|
|
466
521
|
|
|
467
522
|
// ─── CLI entry point ──────────────────────────────────────────────────────────
|