@starci/skills 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INDEX.md +3 -1
- package/INDEX.vi.md +3 -1
- package/README.md +6 -1
- package/README.vi.md +6 -1
- package/knowledge/ui/presentation/INDEX.md +5 -0
- package/knowledge/ui/presentation/INDEX.vi.md +1 -0
- package/knowledge/ui/presentation/radius.md +183 -0
- package/knowledge/ui/presentation/radius.vi.md +182 -0
- package/operators/INDEX.md +5 -5
- package/operators/INDEX.vi.md +5 -5
- package/operators/architecture-decide/operator.md +27 -4
- package/operators/architecture-decide/operator.vi.md +23 -4
- package/operators/architecture-decide/self-test.mjs +18 -1
- package/operators/architecture-decide/validate.mjs +29 -1
- package/operators/backend-source-apply/operator.md +197 -182
- package/operators/backend-source-apply/operator.vi.md +190 -178
- package/operators/backend-source-apply/self-test.mjs +257 -257
- package/operators/backend-source-apply/validate.mjs +240 -240
- package/operators/business-decide/self-test.mjs +2 -1
- package/operators/business-decide/validate.mjs +3 -0
- package/operators/content-generate/self-test.mjs +1 -1
- package/operators/frontend-direction-decide/self-test.mjs +1 -1
- package/operators/frontend-presentation-resolve/operator.md +6 -0
- package/operators/frontend-presentation-resolve/operator.vi.md +6 -0
- package/operators/frontend-presentation-resolve/self-test.mjs +6 -5
- package/operators/frontend-presentation-resolve/validate.mjs +30 -6
- package/operators/frontend-source-apply/operator.md +3 -1
- package/operators/frontend-source-apply/operator.vi.md +3 -1
- package/operators/frontend-source-apply/self-test.mjs +1 -1
- package/operators/frontend-surface-audit/self-test.mjs +1 -1
- package/operators/git-publish/self-test.mjs +1 -1
- package/operators/platform-operate/self-test.mjs +1 -1
- package/operators/quality-verify/operator.md +5 -1
- package/operators/quality-verify/operator.vi.md +5 -1
- package/operators/quality-verify/self-test.mjs +15 -2
- package/operators/quality-verify/validate.mjs +21 -1
- package/operators/release-deploy/self-test.mjs +235 -235
- package/operators/uat-verify/operator.md +20 -6
- package/operators/uat-verify/operator.vi.md +21 -7
- package/operators/uat-verify/self-test.mjs +6 -5
- package/operators/uat-verify/validate.mjs +10 -4
- package/operators/workspace-bind/operator.md +18 -4
- package/operators/workspace-bind/operator.vi.md +16 -4
- package/operators/workspace-bind/self-test.mjs +3 -1
- package/operators/workspace-bind/validate.mjs +1 -0
- package/package.json +1 -1
- package/readiness/initialization/workspaces/local-route.schema.json +1 -1
- package/resources/orchestrator.json +14 -8
- package/routing.json +2 -1
- package/scripts/validate-request.mjs +12 -1
- package/scripts/validate-response.mjs +14 -3
- package/scripts/validate-step.mjs +2 -1
- package/scripts/validate-workflows.mjs +8 -1
- package/templates/kinds/architecture-decision.contract.json +1 -0
- package/templates/kinds/architecture-decision.skeleton.md +6 -0
- package/templates/kinds/backend-source-application.contract.json +1 -1
- package/templates/kinds/backend-source-application.skeleton.md +1 -1
- package/templates/kinds/frontend-presentation-resolution.contract.json +1 -1
- package/templates/kinds/frontend-presentation-resolution.skeleton.md +1 -0
- package/templates/kinds/mutations.schema.json +65 -65
- package/templates/kinds/route.schema.json +4 -2
- package/templates/kinds/stack-model.schema.json +23 -1
- package/templates/kinds/workspace-route-binding.contract.json +2 -2
- package/templates/step/response.schema.json +10 -0
- package/templates/step/state.schema.json +205 -0
- package/workflows/README.md +3 -0
- package/workflows/README.vi.md +3 -0
- package/workflows/backend-feature.json +26 -5
- package/workflows/content-unit.json +4 -1
- package/workflows/frontend-new-surface.json +15 -5
- package/workflows/frontend-reconstruct.json +10 -4
- package/workflows/frontend-refine.json +10 -4
- package/workflows/frontend-with-uat.json +15 -3
- package/workflows/full-feature.json +31 -8
- package/workflows/release.json +7 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Proves validate.mjs on a synthetic session branch: one conforming resolution of a three-node tree
|
|
2
2
|
// (one application-owned gap, one Grammar-owned padding, one recorded gap), one branch with the
|
|
3
|
-
// contract emission off, one blocked on RULE_MISSING before anything was written,
|
|
4
|
-
// per law, each of which must fail with a line that names the defect.
|
|
3
|
+
// contract emission off, one blocked on RULE_MISSING before anything was written, one whose gap rule
|
|
4
|
+
// claims no attribute because its node publishes no path for one, and one mutation per law, each of which must fail with a line that names the defect.
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
@@ -107,7 +107,7 @@ function writeBranch(files) {
|
|
|
107
107
|
for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
|
|
108
108
|
mkdirSync(path.join(session, 'step-1', 'parallel-1', 'response'), { recursive: true });
|
|
109
109
|
writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'response.md'), '# frontend-direction-decision — plan-picker\n');
|
|
110
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1'], ['2/1']], steps: { '1/1': 'frontend.direction.decide', '2/1': 'frontend.presentation.resolve' }, current: '2/1', status: 'running' }));
|
|
110
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1'], ['2/1']], steps: { '1/1': 'frontend.direction.decide', '2/1': 'frontend.presentation.resolve' }, current: '2/1', status: 'running' }));
|
|
111
111
|
for (const [name, content] of Object.entries(files)) {
|
|
112
112
|
if (content === null) continue;
|
|
113
113
|
writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
@@ -140,13 +140,14 @@ const mutate = (change) => { const m = inventory(); change(m); return { ...basel
|
|
|
140
140
|
await expectValid(baseline(), 'one application-owned gap, one Grammar-owned padding, one recorded gap');
|
|
141
141
|
await expectValid({ ...baseline(), 'request/request.json': requestJson({ extra: { contractEmission: 'off' } }), [TREE]: resolvedTree({ emission: 'off' }) }, 'contract emission off leaves the tree bare');
|
|
142
142
|
await expectValid({ 'request/request.json': requestJson(), 'response/response.json': responseJson({ status: 'blocked', stop: 'RULE_MISSING', next: [], fields: {} }) }, 'blocked on RULE_MISSING before anything was written');
|
|
143
|
+
await expectValid({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-1"', '') }, 'a rule whose only node is recorded under Gaps claims no attribute');
|
|
143
144
|
|
|
144
145
|
await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { mystery: 1 } }) }, 'requirements.mystery is not a field', 'undeclared requirement');
|
|
145
146
|
await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { maxRounds: 0 } }) }, 'maxRounds must be a positive whole number', 'no rounds allowed');
|
|
146
147
|
await expectError(mutate((m) => { m.ruleIds.push('GAP-99'); }), 'is outside the published presentation inventory', 'unpublished rule');
|
|
147
148
|
await expectError(mutate((m) => { m.classNames.push('gap-11'); }), 'is in the inventory and not in the resolved tree', 'class that never reached the tree');
|
|
148
149
|
await expectError(mutate((m) => { m.treeFingerprint = 'nope'; }), 'treeFingerprint', 'inventory schema');
|
|
149
|
-
await expectError({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-
|
|
150
|
+
await expectError({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-5"', '') }, 'no node claims it under data-contract', 'applied rule with no claim');
|
|
150
151
|
await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { contractEmission: 'off' } }) }, 'contract emission is off and the resolved tree carries a data-contract', 'claims written under emission off');
|
|
151
152
|
await expectError({ ...baseline(), 'response/response.md': responseMd().replace('| `body>main` | `GAP-5` | `gap-6` |', '| `body>main` | `GAP-5` | `gap-5` |'), 'response/data/inventory.json': (() => { const m = inventory(); m.classNames = ['gap-5', 'gap-1']; return m; })(), [TREE]: resolvedTree().replace('gap-6', 'gap-5') }, 'renders GAP-5 as gap-5, expected step 6', 'ordinal written as the step');
|
|
152
153
|
await expectError({ ...baseline(), 'response/response.md': responseMd().replace(`| \`${SECTION}\` | padding | grammar | \`PADDING-4\` |`, `| \`${SECTION}\` | padding | app | \`PADDING-4\` |`) }, 'owns PADDING-4 and chooses no class for it', 'application claims an owned property and writes nothing');
|
|
@@ -160,4 +161,4 @@ await expectError({ ...baseline(), 'response/data/inventory.json': null, 'respon
|
|
|
160
161
|
await expectError({ ...baseline(), 'response/response.json': responseJson({ status: 'blocked', stop: 'MADE_UP_CODE', next: [] }) }, 'not a registered code', 'unknown stop code');
|
|
161
162
|
await expectError({ ...baseline(), 'response/response.json': responseJson({ fallbacks: ['RULE_MISSING'] }) }, 'has disposition terminate under these requirements', 'a terminate code taken as a fallback');
|
|
162
163
|
|
|
163
|
-
process.stdout.write('frontend.presentation.resolve self-test:
|
|
164
|
+
process.stdout.write('frontend.presentation.resolve self-test: 4 valid branches, 18 rejected mutations\n');
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// carries appears in the resolved tree; a Grammar-owned property emits no application class while an
|
|
4
4
|
// application-owned one emits a class whose step matches the ordinal of its rule; the gaps the
|
|
5
5
|
// receipt lists are exactly the gaps the inventory records; and with emission on every claimed rule
|
|
6
|
-
// reaches the tree as a data-contract token
|
|
6
|
+
// reaches the tree as a data-contract token, except a rule whose every node the receipt records under
|
|
7
|
+
// `## Gaps`, which has no attribute to reach.
|
|
7
8
|
import { existsSync } from 'node:fs';
|
|
8
9
|
import { readFile, readdir } from 'node:fs/promises';
|
|
9
10
|
import path from 'node:path';
|
|
@@ -67,6 +68,21 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
|
|
|
67
68
|
if (!published.has(ruleId)) errors.push(`${at}: rule ${ruleId} is outside the published presentation inventory (UNKNOWN_RULE)`);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
// The receipt is read before the tree, because a rule recorded under `## Gaps` is a rule whose node
|
|
72
|
+
// publishes no path for the attribute: the emission check below must know that before it accuses the
|
|
73
|
+
// tree of hiding a claim that could never have been written.
|
|
74
|
+
const receiptText = present.has('frontend-presentation-resolution') && has('response/response.md') ? await read('response/response.md') : null;
|
|
75
|
+
const gapRows = receiptText === null ? [] : (tableUnder(receiptText, '## Gaps') ?? []);
|
|
76
|
+
const gapNodes = new Set(gapRows.map(([node]) => node));
|
|
77
|
+
const chosenRows = receiptText === null ? [] : (tableUnder(receiptText, '## Rules chosen') ?? []);
|
|
78
|
+
// A rule is exempt only when every node that chose it is a gap node; one ordinary node still owes
|
|
79
|
+
// its attribute.
|
|
80
|
+
const gapOnlyRules = new Set();
|
|
81
|
+
for (const [, rule] of chosenRows) {
|
|
82
|
+
const nodes = chosenRows.filter(([, r]) => r === rule).map(([n]) => n);
|
|
83
|
+
if (nodes.length && nodes.every((n) => gapNodes.has(n))) gapOnlyRules.add(String(rule).replace(/`/g, ''));
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
// The resolved tree is the only place the classes may land, so every class the inventory claims
|
|
71
87
|
// must actually be in it.
|
|
72
88
|
const treeRefs = list(response.fields?.['resolved-tree']);
|
|
@@ -79,19 +95,23 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
|
|
|
79
95
|
}
|
|
80
96
|
if (emission === 'on') {
|
|
81
97
|
for (const ruleId of inventory.ruleIds) {
|
|
98
|
+
// A property the application owns on a Grammar component's className carries no attribute:
|
|
99
|
+
// the component forwards className and nothing else, so the claim is recorded under `## Gaps`
|
|
100
|
+
// instead of being demanded of a tree that cannot carry it.
|
|
101
|
+
if (gapOnlyRules.has(ruleId)) continue;
|
|
82
102
|
const claimed = [...tree.matchAll(/data-contract="([^"]*)"/g)].some((m) => m[1].split(/\s+/).includes(ruleId));
|
|
83
103
|
if (!claimed) errors.push(`${at}: rule ${ruleId} is applied and no node claims it under data-contract`);
|
|
84
104
|
}
|
|
85
105
|
} else if (/data-contract=/.test(tree)) errors.push('response/artifacts: contract emission is off and the resolved tree carries a data-contract attribute');
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
if (
|
|
89
|
-
const text =
|
|
108
|
+
if (receiptText !== null) {
|
|
109
|
+
const text = receiptText;
|
|
90
110
|
const rel = 'response/response.md';
|
|
91
111
|
const owners = tableUnder(text, '## Owner map') ?? [];
|
|
92
|
-
const chosen =
|
|
112
|
+
const chosen = chosenRows;
|
|
93
113
|
const removed = tableUnder(text, '## Removed') ?? [];
|
|
94
|
-
const gaps =
|
|
114
|
+
const gaps = gapRows;
|
|
95
115
|
|
|
96
116
|
const appRules = new Set();
|
|
97
117
|
const grammarRules = new Set();
|
|
@@ -129,7 +149,11 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
|
|
|
129
149
|
const receiptGaps = gaps.map(([node, property, missing]) => `${node}|${property}|${missing}`).sort();
|
|
130
150
|
const dataGaps = inventory.gaps.map((g) => `${g.nodePath}|${g.property}|${g.missingPath}`).sort();
|
|
131
151
|
if (receiptGaps.join('\n') !== dataGaps.join('\n')) {
|
|
132
|
-
|
|
152
|
+
// Two counts say nothing about which row is wrong, so the first row that differs is named.
|
|
153
|
+
const width = Math.max(receiptGaps.length, dataGaps.length);
|
|
154
|
+
let first = 0;
|
|
155
|
+
while (first < width && receiptGaps[first] === dataGaps[first]) first += 1;
|
|
156
|
+
errors.push(`${rel}: the Gaps table and inventory.gaps differ (${receiptGaps.length} rows against ${dataGaps.length}); the first differing row is the receipt's ${receiptGaps[first] ?? '(absent)'} against the inventory's ${dataGaps[first] ?? '(absent)'}`);
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
159
|
return { errors };
|
|
@@ -95,7 +95,9 @@ knows what it wrote, never how it renders.
|
|
|
95
95
|
| 7 | Emit | — | everything above | `response/response.md`, `response/changes.md`, `response/response.json` | — |
|
|
96
96
|
|
|
97
97
|
Under `mode = dry` the branch stops after step 4 with the plan alone: `writes.json` carries a null
|
|
98
|
-
commit, `response.json` carries no commit, and the checkout is untouched.
|
|
98
|
+
commit, `response.json` carries no commit, and the checkout is untouched. A dry run is granted neither
|
|
99
|
+
`@tools/sourcewrite` nor `@tools/git`, because a mode that writes nothing needs no tool that can
|
|
100
|
+
write; the grant and this paragraph say the same thing so neither can drift. Under `apply`, step 5 writes
|
|
99
101
|
and commits exactly once and step 6 proves that the committed tree is the resolved tree. `changes.md`
|
|
100
102
|
is the record the next steps read: which paths moved, which claims they carry, which gates the
|
|
101
103
|
checkout pins for them, and which surfaces must now be observed.
|
|
@@ -94,7 +94,9 @@ không bao giờ biết nó render ra sao.
|
|
|
94
94
|
| 7 | Phát | — | mọi thứ ở trên | `response/response.md`, `response/changes.md`, `response/response.json` | — |
|
|
95
95
|
|
|
96
96
|
Dưới `mode = dry`, nhánh dừng sau bước 4 với riêng bản kế hoạch: `writes.json` mang commit rỗng,
|
|
97
|
-
`response.json` không mang commit nào, và checkout y nguyên.
|
|
97
|
+
`response.json` không mang commit nào, và checkout y nguyên. Một lượt dry không được cấp
|
|
98
|
+
`@tools/sourcewrite` lẫn `@tools/git`, vì một chế độ không ghi gì thì không cần công cụ nào ghi được;
|
|
99
|
+
phần cấp quyền và đoạn này nói cùng một điều nên chúng không thể trôi khỏi nhau. Dưới `apply`, bước 5 ghi rồi commit đúng
|
|
98
100
|
một lần và bước 6 chứng minh rằng cây đã commit đúng là cây đã resolve. `changes.md` là bản ghi mà
|
|
99
101
|
các bước sau đọc: path nào đã dịch chuyển, chúng mang lời khai nào, checkout ghim cổng nào cho chúng,
|
|
100
102
|
và bề mặt nào bây giờ phải được quan sát.
|
|
@@ -145,7 +145,7 @@ function writeBranch(files, { inventoryDoc = inventory(), withInventory = true }
|
|
|
145
145
|
writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'response.md'), '# frontend-direction-decision — plan-picker\n');
|
|
146
146
|
writeFileSync(path.join(session, 'step-2', 'parallel-1', 'response', 'response.md'), '# frontend-presentation-resolution — plan-picker\n');
|
|
147
147
|
if (withInventory) writeFileSync(path.join(upstream, 'inventory.json'), JSON.stringify(inventoryDoc, null, 2));
|
|
148
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: SESSION, chain: [['2/1'], ['3/1']], steps: { '2/1': 'frontend.presentation.resolve', '3/1': 'frontend.source.apply' }, current: '3/1', status: 'running' }));
|
|
148
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: SESSION, project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['2/1'], ['3/1']], steps: { '2/1': 'frontend.presentation.resolve', '3/1': 'frontend.source.apply' }, current: '3/1', status: 'running' }));
|
|
149
149
|
for (const [name, content] of Object.entries(files)) {
|
|
150
150
|
if (content === null) continue;
|
|
151
151
|
writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
@@ -123,7 +123,7 @@ function writeBranch(files) {
|
|
|
123
123
|
mkdirSync(dir, { recursive: true });
|
|
124
124
|
writeFileSync(path.join(dir, 'response.md'), `# ${name} — plan-picker\n`);
|
|
125
125
|
}
|
|
126
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['3/1'], ['4/1']], steps: { '3/1': 'frontend.source.apply', '4/1': 'frontend.surface.audit' }, current: '4/1', status: 'running' }));
|
|
126
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['3/1'], ['4/1']], steps: { '3/1': 'frontend.source.apply', '4/1': 'frontend.surface.audit' }, current: '4/1', status: 'running' }));
|
|
127
127
|
for (const [name, content] of Object.entries(files)) {
|
|
128
128
|
if (content === null) continue;
|
|
129
129
|
writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
@@ -102,7 +102,7 @@ function writeBranch(files) {
|
|
|
102
102
|
const session = mkdtempSync(path.join(tmpdir(), 'publish-session-'));
|
|
103
103
|
const branch = path.join(session, 'step-1', 'parallel-1');
|
|
104
104
|
for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
|
|
105
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'git.publish' }, current: '1/1', status: 'running' }));
|
|
105
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'git.publish' }, current: '1/1', status: 'running' }));
|
|
106
106
|
for (const name of ['response.md', 'changes.md', 'quality.md']) writeFileSync(path.join(branch, 'response', name), '# placeholder\n');
|
|
107
107
|
for (const [name, content] of Object.entries(files)) {
|
|
108
108
|
if (content === null) continue;
|
|
@@ -122,7 +122,7 @@ function writeBranch(files) {
|
|
|
122
122
|
const session = mkdtempSync(path.join(tmpdir(), 'platform-session-'));
|
|
123
123
|
const branch = path.join(session, 'step-1', 'parallel-1');
|
|
124
124
|
for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
|
|
125
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'platform.operate' }, current: '1/1', status: 'running' }));
|
|
125
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'platform.operate' }, current: '1/1', status: 'running' }));
|
|
126
126
|
for (const [name, content] of Object.entries(files)) {
|
|
127
127
|
if (content === null) continue;
|
|
128
128
|
writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
@@ -15,7 +15,11 @@ predecessor receipt must report the same source head, and that head must be the
|
|
|
15
15
|
`request/request.json` froze; two predecessors on different heads describe two different deliveries,
|
|
16
16
|
and gating the union of them measures something nobody built. That is `PREDECESSOR_MIXED`, refused
|
|
17
17
|
before a single command runs rather than discovered later as a confusing gate failure. A predecessor
|
|
18
|
-
whose fingerprint no longer matches the frozen source is `PREDECESSOR_STALE`.
|
|
18
|
+
whose fingerprint no longer matches the frozen source is `PREDECESSOR_STALE`. So is a predecessor
|
|
19
|
+
produced under `mode: dry`: it carries no commit and describes a plan rather than a delivery, its
|
|
20
|
+
change record says `nothing written` and its receipt's `Commit` reads `—`, and it is refused at step 2
|
|
21
|
+
before any command runs, because a plan has no head to stand on and gating the base head would
|
|
22
|
+
publish a green verdict about code nobody wrote. What a predecessor
|
|
19
23
|
decided is consumed unchanged: this operator never re-plans the delivery, re-opens its boundary, or
|
|
20
24
|
forms an opinion about whether the change was a good one.
|
|
21
25
|
|
|
@@ -15,7 +15,11 @@ tiền nhiệm phải báo cùng một source head, và head đó phải là hea
|
|
|
15
15
|
băng; hai tiền nhiệm trên hai head khác nhau mô tả hai delivery khác nhau, và gác hợp của chúng là đo
|
|
16
16
|
một thứ không ai xây. Đó là `PREDECESSOR_MIXED`, bị từ chối trước khi có lệnh nào chạy thay vì lộ ra
|
|
17
17
|
về sau dưới dạng một lỗi cổng khó hiểu. Một tiền nhiệm có fingerprint không còn khớp source đã đóng
|
|
18
|
-
băng là `PREDECESSOR_STALE`.
|
|
18
|
+
băng là `PREDECESSOR_STALE`. Một tiền nhiệm làm ra dưới `mode: dry` cũng vậy: nó không mang commit nào
|
|
19
|
+
và mô tả một bản kế hoạch chứ không phải một delivery, bản ghi thay đổi của nó ghi `nothing written`
|
|
20
|
+
và ô `Commit` của biên nhận ghi `—`, nên nó bị từ chối ở bước 2 trước khi có lệnh nào chạy, bởi một
|
|
21
|
+
bản kế hoạch không có head nào để đứng lên và gác head gốc là công bố một phán quyết xanh về đoạn code
|
|
22
|
+
chưa ai viết. Điều tiền nhiệm đã quyết được tiêu thụ nguyên vẹn: operator này không
|
|
19
23
|
bao giờ lập lại kế hoạch delivery, mở lại ranh giới của nó, hay có ý kiến về việc thay đổi ấy hay dở.
|
|
20
24
|
|
|
21
25
|
## Head được xác nhận ngay trong cổng vào
|
|
@@ -109,10 +109,12 @@ function writeBranch(files) {
|
|
|
109
109
|
const session = mkdtempSync(path.join(tmpdir(), 'quality-session-'));
|
|
110
110
|
const branch = path.join(session, 'step-1', 'parallel-1');
|
|
111
111
|
for (const d of ['request', 'response/data/gates', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
|
|
112
|
-
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'quality.verify' }, current: '1/1', status: 'running' }));
|
|
112
|
+
writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'quality.verify' }, current: '1/1', status: 'running' }));
|
|
113
113
|
mkdirSync(path.join(session, 'step-1', 'parallel-1', 'response'), { recursive: true });
|
|
114
114
|
writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'changes.md'), '# changes\n');
|
|
115
|
+
if (files.__predecessorChanges !== undefined) writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'changes.md'), files.__predecessorChanges);
|
|
115
116
|
for (const [name, content] of Object.entries(files)) {
|
|
117
|
+
if (name === '__predecessorChanges') continue;
|
|
116
118
|
if (content === null) continue;
|
|
117
119
|
writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
118
120
|
}
|
|
@@ -206,5 +208,16 @@ await expectError(baseline({ 'response/response.md': responseMd().replace('## Ve
|
|
|
206
208
|
await expectError(baseline({ 'response/data/gates/lint.json': { ...gateResult('lint'), sourceHead: 'nope' } }), 'sourceHead', 'gate-result schema');
|
|
207
209
|
await expectError(baseline({ 'response/response.json': responseJson({ gates: ['response/data/gates/format.json', 'response/data/gates/lint.json'] }) }), 'gate-result does not list', 'a gate file the response never lists');
|
|
208
210
|
await expectError(baseline({ 'request/request.json': requestJson({ inputs: {} }) }), 'needs at least one of', 'a verification with no producer receipt');
|
|
211
|
+
await expectError(baseline({ __predecessorChanges: `# changes — backend.source.apply step-1/parallel-1
|
|
209
212
|
|
|
210
|
-
|
|
213
|
+
## Binding
|
|
214
|
+
|
|
215
|
+
| Field | Value |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| Operator | \`backend.source.apply\` |
|
|
218
|
+
| Step | \`step-1/parallel-1\` |
|
|
219
|
+
| Checkout | \`@workspaces/be\` at \`${HEAD}\` on \`session/s-test\`, nothing written |
|
|
220
|
+
| Predecessor | \`step-1/parallel-1/response/response.md\` |
|
|
221
|
+
` }), 'PREDECESSOR_STALE', 'a predecessor produced under mode dry');
|
|
222
|
+
|
|
223
|
+
process.stdout.write('quality.verify self-test: 3 valid branches, 26 rejected mutations\n');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// quality.verify's own law over one branch, on top of the shared step check: the gate plan the
|
|
2
2
|
// person declared is the plan that ran, one file per gate; every gate file stands on the same head,
|
|
3
|
-
// which is the head the request pinned and the commit the predecessor recorded
|
|
3
|
+
// which is the head the request pinned and the commit the predecessor recorded, and no predecessor was
|
|
4
|
+
// produced under mode dry; e2e runs only under
|
|
4
5
|
// an explicit request and sonar carries the scope it was measured at; a result's exit code, evidence
|
|
5
6
|
// and classification match its status; coverage exists exactly where the unit gate produced it and no
|
|
6
7
|
// metric sits below its own threshold beside a green unit result; a debt is live, in-boundary and
|
|
@@ -11,6 +12,7 @@ import path from 'node:path';
|
|
|
11
12
|
import process from 'node:process';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { validateStep } from '../../scripts/validate-step.mjs';
|
|
15
|
+
import { sessionRootOf } from '../../scripts/validate-request.mjs';
|
|
14
16
|
import { tableUnder } from '../../scripts/validate-response.mjs';
|
|
15
17
|
|
|
16
18
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
@@ -74,6 +76,24 @@ export async function validateQualityStep(branchDir, root = ROOT) {
|
|
|
74
76
|
for (const d of debts) if (!plannedNames.includes(d.gate)) errors.push(`request.json: debt ${d.debtId} covers ${d.gate}, which is not a planned gate`);
|
|
75
77
|
|
|
76
78
|
// At least one producer receipt: a verification with no predecessor has no delivery to measure.
|
|
79
|
+
// A dry predecessor is a plan, not a delivery: it has no commit, so there is no head for a gate to
|
|
80
|
+
// stand on. It is refused here, at step 2, rather than later as a confusing gate failure.
|
|
81
|
+
const sessionRoot = sessionRootOf(branchDir);
|
|
82
|
+
if (sessionRoot) {
|
|
83
|
+
for (const kind of ['changes', 'backend-source-application', 'frontend-source-application']) {
|
|
84
|
+
const ref = request?.inputs?.[kind];
|
|
85
|
+
if (!ref) continue;
|
|
86
|
+
const full = path.join(sessionRoot, ref);
|
|
87
|
+
if (!existsSync(full)) continue;
|
|
88
|
+
let text; try { text = await readFile(full, 'utf8'); } catch { continue; }
|
|
89
|
+
const binding = Object.fromEntries((tableUnder(text, '## Binding') ?? []).map(([k, v]) => [k, v]));
|
|
90
|
+
const wroteNothing = kind === 'changes'
|
|
91
|
+
? /nothing written/.test(String(binding.Checkout ?? ''))
|
|
92
|
+
: empty(binding.Commit);
|
|
93
|
+
if (wroteNothing) errors.push(`request.json: PREDECESSOR_STALE — the ${kind} input ${ref} was produced under mode dry and carries no commit; a plan has no head for a gate to stand on`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
const inputs = Object.keys(request?.inputs ?? {});
|
|
78
98
|
if (response.status !== 'blocked' && !inputs.some((k) => ['backend-source-application', 'frontend-source-application', 'changes'].includes(k))) {
|
|
79
99
|
errors.push('request.json: quality.verify needs at least one of backend-source-application, frontend-source-application, changes');
|