cabloy 5.1.194 → 5.1.196
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/.cabloy-version +1 -1
- package/.github/workflows/agent-governance.yml +1 -0
- package/CHANGELOG.md +22 -0
- package/README.md +13 -24
- package/package.json +2 -1
- package/repo-agent-governance/managed-assets.json +138 -33
- package/repo-agent-governance/scripts/pack-check.mjs +17 -0
- package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +1 -0
- package/repo-agent-governance/skills/cabloy-domain-planning/SKILL.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +13 -8
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/evals.json +108 -12
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/files/scenarios.json +84 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/protocol.md +9 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +21 -5
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +78 -168
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/evals.json +165 -17
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/files/scenarios.json +114 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/protocol.md +44 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +145 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-aware-discovery.md +55 -57
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +6 -4
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +12 -8
- package/repo-agent-governance/tests/governance.test.mjs +10 -1
- package/repo-agent-governance/tests/spec-audit.test.mjs +255 -0
- package/repo-agent-governance/tools/spec-audit/audit.mjs +427 -0
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +97 -408
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +462 -1
- package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +561 -0
- package/repo-docs/.vitepress/config.mjs +54 -29
- package/repo-docs/ai/playbook-spec-execution.md +10 -4
- package/repo-docs/ai/playbook-spec-generation.md +40 -3
- package/repo-docs/ai/skills.md +3 -3
- package/repo-docs/backend/controller-aop-guide.md +10 -0
- package/repo-docs/backend/field-indexes.md +25 -0
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
- package/repo-docs/fullstack/development-history.md +24 -0
- package/repo-docs/fullstack/introduction.md +25 -156
- package/repo-docs/fullstack/quickstart.md +1 -1
- package/repo-docs/fullstack/ssr-entry-modes.md +52 -0
- package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +3 -1
- package/repo-docs/fullstack/tutorial-5-backend-contract-sharing.md +13 -7
- package/repo-docs/fullstack/tutorial-6-one-contract-four-uses.md +11 -15
- package/repo-docs/fullstack/tutorials-overview.md +2 -2
- package/repo-docs/index.md +23 -45
- package/repo-docs/public/cabloy.png +0 -0
- package/repo-docs/public/cabloy.svg +3 -0
- package/repo-docs/public/favicon.svg +3 -0
- package/repo-docs/reference/repo-scripts.md +6 -1
- package/repo-e2e/specs/home-user-account.spec.ts +311 -207
- package/scripts/bootstrapAgentGovernance.mjs +1 -0
- package/scripts/upgrade.ts +1 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/package.json +8 -2
- package/vona/packages-cli/cli-set-api/src/index.ts +1 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -12
- package/vona/packages-cli/cli-set-api/src/lib/mergeMetaIndex.ts +121 -0
- package/vona/packages-cli/cli-set-api/test/indexSnippets.test.ts +41 -0
- package/vona/packages-cli/cli-set-api/test/mergeMetaIndex.test.ts +78 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +6 -6
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +25 -4
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuUniqueness.test.ts +161 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +21 -20
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +165 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +16 -13
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/promotionIndexes.test.ts +82 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +23 -21
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/tradeIndexes.test.ts +86 -0
- package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +379 -376
- package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +60 -0
- package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +164 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +35 -26
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/package.json +5 -5
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionFiber_.ts +6 -2
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transaction_.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +3 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- /package/repo-docs/{.vitepress/public → public}/CNAME +0 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { auditPlanning } from '../tools/spec-audit/audit.mjs';
|
|
10
|
+
|
|
11
|
+
const CLI = fileURLToPath(new URL('../tools/spec-audit/audit.mjs', import.meta.url));
|
|
12
|
+
const BASELINE = {
|
|
13
|
+
'README.md': '# Synthetic Planning\n\n[Product](./prd.md#requirements)\n',
|
|
14
|
+
'prd.md':
|
|
15
|
+
'# Product\n\n## Requirements\n\n### PRD-DEMO-01: Product outcome\n\nTraceability: `SRS-DEMO-01`\n',
|
|
16
|
+
'srs.md':
|
|
17
|
+
'# Technical Contracts\n\n### SRS-DEMO-01: Contract\n\nTraceability: `PRD-DEMO-01`, `WBS-DEMO-10-01`\n',
|
|
18
|
+
'pdp-wbs.md':
|
|
19
|
+
'# Delivery\n\n### Phase 10: Baseline\n\nDependencies: none.\n\n#### WBS-DEMO-10-01: Implement contract\n\nTraceability: `SRS-DEMO-01`, `ATP-DEMO-01`\n\nCompletion checks: inspect synthetic output.\n',
|
|
20
|
+
'test-plan.md':
|
|
21
|
+
'# Acceptance\n\n## Acceptance Scenarios\n\n### ATP-DEMO-01: Verify contract\n\nSetup: synthetic state.\n\nProcedure: inspect output.\n\nExpected result: contract holds.\n\nMinimum proof: redacted observation.\n\nTraceability: `WBS-DEMO-10-01`\n',
|
|
22
|
+
'progress.md':
|
|
23
|
+
'# Progress\n\n| WBS ID | Work item | Status |\n| --- | --- | --- |\n| WBS-DEMO-10-01 | Implement contract | not-started |\n',
|
|
24
|
+
'decisions/0001-boundary.md': '# Boundary\n\nStatus: Proposed\n',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function fixture(t, records = BASELINE) {
|
|
28
|
+
const directory = await mkdtemp(resolve(tmpdir(), 'cabloy-spec-audit-'));
|
|
29
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
30
|
+
for (const [file, markdown] of Object.entries(records)) {
|
|
31
|
+
await mkdir(dirname(resolve(directory, file)), { recursive: true });
|
|
32
|
+
await writeFile(resolve(directory, file), markdown);
|
|
33
|
+
}
|
|
34
|
+
return directory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function snapshot(directory, prefix = '') {
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const entry of await readdir(resolve(directory, prefix), { withFileTypes: true })) {
|
|
40
|
+
const file = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
41
|
+
if (entry.isDirectory()) Object.assign(result, await snapshot(directory, file));
|
|
42
|
+
else result[file] = await readFile(resolve(directory, file), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function codes(result) {
|
|
48
|
+
return result.diagnostics.map(diagnostic => diagnostic.code);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('complete baseline checks exact traceability without treating a Proposed ADR as accepted', async t => {
|
|
52
|
+
const directory = await fixture(t);
|
|
53
|
+
const result = await auditPlanning(directory);
|
|
54
|
+
assert.deepEqual(result.diagnostics, []);
|
|
55
|
+
assert.equal(result.definitions, 4);
|
|
56
|
+
assert.equal(result.ok, true);
|
|
57
|
+
assert.match(result.limitations, /approval.*evidence authenticity/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('canonical atomic bullets carry their own inline traceability', async t => {
|
|
61
|
+
const records = {
|
|
62
|
+
...BASELINE,
|
|
63
|
+
'prd.md':
|
|
64
|
+
'# Product\n\n## Requirements\n\n- **PRD-DEMO-01**: Product outcome. Traceability: `SRS-DEMO-01`.\n',
|
|
65
|
+
'srs.md':
|
|
66
|
+
'# Technical Contracts\n\n- **SRS-DEMO-01**: Contract. Traceability: `PRD-DEMO-01`, `WBS-DEMO-10-01`.\n',
|
|
67
|
+
};
|
|
68
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
69
|
+
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('explicit four-column matrix supplies exact adjacent traceability', async t => {
|
|
73
|
+
const records = Object.fromEntries(
|
|
74
|
+
Object.entries(BASELINE).map(([file, value]) => [
|
|
75
|
+
file,
|
|
76
|
+
value.replace(/^Traceability:.*\n/gm, ''),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
records['README.md'] +=
|
|
80
|
+
'\n| PRD | SRS | WBS | ATP |\n| --- | --- | --- | --- |\n| PRD-DEMO-01 | SRS-DEMO-01 | WBS-DEMO-10-01 | ATP-DEMO-01 |\n';
|
|
81
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
82
|
+
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('ordinary co-occurrence and wildcard matrices cannot manufacture traceability', async t => {
|
|
86
|
+
const records = Object.fromEntries(
|
|
87
|
+
Object.entries(BASELINE).map(([file, value]) => [
|
|
88
|
+
file,
|
|
89
|
+
value.replace(/^Traceability:.*\n/gm, ''),
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
records['README.md'] +=
|
|
93
|
+
'\nPRD-DEMO-01 SRS-DEMO-01 WBS-DEMO-10-01 ATP-DEMO-01\n\n| PRD | SRS | WBS | ATP |\n| --- | --- | --- | --- |\n| PRD-DEMO-* | SRS-DEMO-* | WBS-DEMO-* | ATP-DEMO-* |\n';
|
|
94
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
95
|
+
assert.equal(result.ok, false);
|
|
96
|
+
assert.equal(
|
|
97
|
+
result.diagnostics.filter(diagnostic => diagnostic.code.startsWith('traceability-')).length,
|
|
98
|
+
6,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('lightweight records do not require absent baseline files or charts', async t => {
|
|
103
|
+
const directory = await fixture(t, {
|
|
104
|
+
'README.md': '# Lightweight\n',
|
|
105
|
+
'prd.md': '### PRD-DEMO-01: Bounded outcome\n',
|
|
106
|
+
});
|
|
107
|
+
const before = await snapshot(directory);
|
|
108
|
+
const result = await auditPlanning(directory, { lightweight: true });
|
|
109
|
+
assert.equal(result.ok, true);
|
|
110
|
+
assert.equal(result.mode, 'lightweight');
|
|
111
|
+
assert.match(result.coverage, /full-chain coverage not checked/);
|
|
112
|
+
assert.deepEqual(result.omittedRecords, ['srs.md', 'pdp-wbs.md', 'test-plan.md', 'progress.md']);
|
|
113
|
+
assert.deepEqual(await snapshot(directory), before);
|
|
114
|
+
const complete = await auditPlanning(directory);
|
|
115
|
+
assert.ok(codes(complete).includes('missing-record'));
|
|
116
|
+
assert.ok(codes(complete).includes('missing-adr'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('optional records are audited; fences, comments and evidence files are not authority', async t => {
|
|
120
|
+
const records = {
|
|
121
|
+
...BASELINE,
|
|
122
|
+
'rollout.md':
|
|
123
|
+
'# Rollout\n\nUse `ATP-MISSING-01`.\n\n```md\n### ATP-EXAMPLE-01: Example\n```\n<!-- PRD-COMMENT-01 -->\n',
|
|
124
|
+
'evidence/run.md': 'PRD-EVIDENCE-01\n',
|
|
125
|
+
};
|
|
126
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
result.diagnostics
|
|
129
|
+
.filter(diagnostic => diagnostic.code === 'undefined-reference')
|
|
130
|
+
.map(diagnostic => [diagnostic.file, diagnostic.line, diagnostic.id]),
|
|
131
|
+
[['rollout.md', 3, 'ATP-MISSING-01']],
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a mention or evidence table is not a formal ATP declaration', async t => {
|
|
136
|
+
const records = {
|
|
137
|
+
...BASELINE,
|
|
138
|
+
'test-plan.md':
|
|
139
|
+
'# Proof\n\n| ATP ID | Evidence | Status |\n| --- | --- | --- |\n| ATP-DEMO-01 | synthetic | verified |\n',
|
|
140
|
+
};
|
|
141
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
142
|
+
assert.ok(codes(result).includes('missing-definition'));
|
|
143
|
+
assert.ok(
|
|
144
|
+
result.diagnostics.some(
|
|
145
|
+
diagnostic => diagnostic.id === 'ATP-DEMO-01' && diagnostic.code === 'undefined-reference',
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('duplicate formal definitions and duplicate progress rows retain diagnostic provenance', async t => {
|
|
151
|
+
const records = {
|
|
152
|
+
...BASELINE,
|
|
153
|
+
'prd.md': `${BASELINE['prd.md']}\n### PRD-DEMO-01: Duplicate\n`,
|
|
154
|
+
'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-01 | Duplicate | verified |\n`,
|
|
155
|
+
};
|
|
156
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
157
|
+
assert.ok(
|
|
158
|
+
result.diagnostics.some(
|
|
159
|
+
diagnostic => diagnostic.file === 'prd.md' && /Duplicate.*line/.test(diagnostic.message),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
assert.ok(
|
|
163
|
+
result.diagnostics.some(
|
|
164
|
+
diagnostic => diagnostic.file === 'progress.md' && /Duplicate.*line/.test(diagnostic.message),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('progress omissions, extras and explicit WBS status conflicts are structural failures', async t => {
|
|
170
|
+
const records = {
|
|
171
|
+
...BASELINE,
|
|
172
|
+
'pdp-wbs.md': `${BASELINE['pdp-wbs.md']}\nStatus: in-progress\n\n#### WBS-DEMO-10-02: Additional task\n\nDependencies: none.\n`,
|
|
173
|
+
'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-99-01 | Unknown | not-started |\n`,
|
|
174
|
+
};
|
|
175
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
176
|
+
for (const code of ['missing-progress', 'unknown-progress', 'status-conflict'])
|
|
177
|
+
assert.ok(codes(result).includes(code), code);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('completed tasks cannot hide unfinished recorded prerequisites', async t => {
|
|
181
|
+
const records = {
|
|
182
|
+
...BASELINE,
|
|
183
|
+
'pdp-wbs.md': `${BASELINE['pdp-wbs.md']}\n#### WBS-DEMO-10-02: Dependent task\n\nDependencies: WBS-DEMO-10-01.\n`,
|
|
184
|
+
'progress.md': `${BASELINE['progress.md']}| WBS-DEMO-10-02 | Dependent | verified |\n`,
|
|
185
|
+
};
|
|
186
|
+
const result = await auditPlanning(await fixture(t, records), { lightweight: true });
|
|
187
|
+
assert.ok(
|
|
188
|
+
result.diagnostics.some(
|
|
189
|
+
diagnostic => diagnostic.id === 'WBS-DEMO-10-02' && diagnostic.code === 'dependency-state',
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('local Markdown destinations, fragments and reference links are checked', async t => {
|
|
195
|
+
const records = {
|
|
196
|
+
...BASELINE,
|
|
197
|
+
'rollout.md':
|
|
198
|
+
'# Rollout\n\n[Missing](./absent.md)\n[Bad fragment](./prd.md#absent)\n[Good][product]\n[Unknown][missing]\n\n[product]: ./prd.md#requirements\n\nPlanned source: `zova/src/target/new.ts`.\n',
|
|
199
|
+
};
|
|
200
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
result.diagnostics.map(diagnostic => [diagnostic.code, diagnostic.line]),
|
|
203
|
+
[
|
|
204
|
+
['link-target', 3],
|
|
205
|
+
['link-fragment', 4],
|
|
206
|
+
['link-reference', 6],
|
|
207
|
+
],
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('exact range endpoints do not masquerade as exact traceability', async t => {
|
|
212
|
+
const records = {
|
|
213
|
+
...BASELINE,
|
|
214
|
+
'prd.md': BASELINE['prd.md'].replace('`SRS-DEMO-01`', '`SRS-DEMO-01` through `SRS-DEMO-01`'),
|
|
215
|
+
};
|
|
216
|
+
// Remove the reverse PRD mapping so the ambiguous range is the sole proposed edge.
|
|
217
|
+
records['srs.md'] = records['srs.md'].replace('`PRD-DEMO-01`, ', '');
|
|
218
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
219
|
+
assert.ok(
|
|
220
|
+
result.diagnostics.some(
|
|
221
|
+
diagnostic => diagnostic.code === 'traceability-outgoing' && diagnostic.id === 'PRD-DEMO-01',
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('definition body boundaries prevent another requirement from supplying an earlier mapping', async t => {
|
|
227
|
+
const records = {
|
|
228
|
+
...BASELINE,
|
|
229
|
+
'prd.md':
|
|
230
|
+
'# Product\n\n## Requirements\n\n### PRD-DEMO-02: Unmapped\n\n### PRD-DEMO-01: Mapped\n\nTraceability: `SRS-DEMO-01`\n',
|
|
231
|
+
};
|
|
232
|
+
const result = await auditPlanning(await fixture(t, records));
|
|
233
|
+
assert.ok(
|
|
234
|
+
result.diagnostics.some(
|
|
235
|
+
diagnostic => diagnostic.id === 'PRD-DEMO-02' && diagnostic.code === 'traceability-outgoing',
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
assert.ok(!result.diagnostics.some(diagnostic => diagnostic.id === 'PRD-DEMO-01'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('CLI emits deterministic JSON, exits nonzero on gaps and never writes planning files', async t => {
|
|
242
|
+
const directory = await fixture(t, { 'README.md': '# Bounded\n\nATP-MISSING-01\n' });
|
|
243
|
+
const before = await snapshot(directory);
|
|
244
|
+
const args = [CLI, directory, '--lightweight', '--format', 'json'];
|
|
245
|
+
const first = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
246
|
+
const second = spawnSync(process.execPath, args, { encoding: 'utf8' });
|
|
247
|
+
assert.equal(first.status, 1);
|
|
248
|
+
assert.equal(first.stdout, second.stdout);
|
|
249
|
+
const result = JSON.parse(first.stdout);
|
|
250
|
+
assert.equal(result.diagnostics[0].id, 'ATP-MISSING-01');
|
|
251
|
+
assert.deepEqual(await snapshot(directory), before);
|
|
252
|
+
const invalid = spawnSync(process.execPath, [CLI, '--fix'], { encoding: 'utf8' });
|
|
253
|
+
assert.equal(invalid.status, 1);
|
|
254
|
+
assert.match(invalid.stderr, /Unknown argument/);
|
|
255
|
+
});
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { access, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, extname, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
findIdentifiers,
|
|
7
|
+
markdownLines,
|
|
8
|
+
parseDefinitions,
|
|
9
|
+
parseProgress,
|
|
10
|
+
parseWbs,
|
|
11
|
+
} from '../spec-charts/spec-parser.mjs';
|
|
12
|
+
|
|
13
|
+
const OWNERS = new Map([
|
|
14
|
+
['PRD-', 'prd.md'],
|
|
15
|
+
['SRS-', 'srs.md'],
|
|
16
|
+
['WBS-', 'pdp-wbs.md'],
|
|
17
|
+
['ATP-', 'test-plan.md'],
|
|
18
|
+
]);
|
|
19
|
+
const CORE = ['README.md', ...OWNERS.values(), 'progress.md'];
|
|
20
|
+
const LIMITS =
|
|
21
|
+
'Static structure only; approval, semantic sufficiency, implementation, and evidence authenticity require human review.';
|
|
22
|
+
|
|
23
|
+
async function exists(path) {
|
|
24
|
+
try {
|
|
25
|
+
await access(path);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function planningFiles(directory, prefix = '') {
|
|
33
|
+
const result = [];
|
|
34
|
+
for (const entry of await readdir(resolve(directory, prefix), { withFileTypes: true })) {
|
|
35
|
+
if (entry.name === 'evidence' || entry.name.startsWith('.')) continue;
|
|
36
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
37
|
+
if (entry.isDirectory()) result.push(...(await planningFiles(directory, path)));
|
|
38
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) result.push(path);
|
|
39
|
+
}
|
|
40
|
+
return result.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cells(line) {
|
|
44
|
+
if (!line.trim().startsWith('|')) return null;
|
|
45
|
+
return line
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/^\||\|$/g, '')
|
|
48
|
+
.split(/(?<!\\)\|/)
|
|
49
|
+
.map(value => value.trim());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function exactIds(value, prefix) {
|
|
53
|
+
// Aggregations are summaries, not exact traceability. Do not count their endpoints.
|
|
54
|
+
if (/[*–—]|\b(?:through|to)\b|`\s*-\s*`|\d\s+-\s+(?:`|PRD-|SRS-|WBS-|ATP-)/.test(value))
|
|
55
|
+
return [];
|
|
56
|
+
return findIdentifiers(value, prefix);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mappingGroups(text) {
|
|
60
|
+
return Array.from(OWNERS.keys(), prefix => exactIds(text, prefix));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addMapping(groups, edges, definitions) {
|
|
64
|
+
for (let index = 0; index < groups.length - 1; index++) {
|
|
65
|
+
for (const from of groups[index]) {
|
|
66
|
+
if (!definitions.has(from)) continue;
|
|
67
|
+
for (const to of groups[index + 1]) {
|
|
68
|
+
if (definitions.has(to)) edges.get(from).add(to);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function collectMappings(records, definitions) {
|
|
75
|
+
const edges = new Map(Array.from(definitions.keys(), id => [id, new Set()]));
|
|
76
|
+
for (const definition of definitions.values()) {
|
|
77
|
+
for (const { text, line } of markdownLines(definition.body ?? '')) {
|
|
78
|
+
const field =
|
|
79
|
+
definition.kind === 'bullet' && line === 1
|
|
80
|
+
? text.slice(text.search(/\bTraceability\s*:/i))
|
|
81
|
+
: text.trim();
|
|
82
|
+
const match = field.match(/^(?:-\s*)?(?:\*\*)?Traceability(?:\*\*)?\s*:(.*)$/i);
|
|
83
|
+
if (!match) continue;
|
|
84
|
+
const groups = mappingGroups(match[1]);
|
|
85
|
+
const index = [...OWNERS.keys()].findIndex(prefix => definition.id.startsWith(prefix));
|
|
86
|
+
groups[index].push(definition.id);
|
|
87
|
+
addMapping(groups, edges, definitions);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const markdown of records.values()) {
|
|
91
|
+
let columns = null;
|
|
92
|
+
for (const { text } of markdownLines(markdown)) {
|
|
93
|
+
const row = cells(text);
|
|
94
|
+
if (!row) {
|
|
95
|
+
columns = null;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (row.every(value => /^:?-+:?$/.test(value))) continue;
|
|
99
|
+
const header = Array.from(OWNERS.keys(), prefix =>
|
|
100
|
+
row.findIndex(value =>
|
|
101
|
+
new RegExp(
|
|
102
|
+
`^${prefix.slice(0, -1)}(?:\\s+(?:IDs?|requirements?|contracts?|tasks?|scenarios?))?$`,
|
|
103
|
+
'i',
|
|
104
|
+
).test(value.replace(/[`*]/g, '').trim()),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
if (header.every(index => index >= 0)) {
|
|
108
|
+
columns = header;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (columns) {
|
|
112
|
+
addMapping(
|
|
113
|
+
columns.map((index, kind) => exactIds(row[index] ?? '', [...OWNERS.keys()][kind])),
|
|
114
|
+
edges,
|
|
115
|
+
definitions,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return edges;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function headingIds(markdown) {
|
|
124
|
+
const slugs = new Set();
|
|
125
|
+
const counts = new Map();
|
|
126
|
+
for (const { heading } of markdownLines(markdown)) {
|
|
127
|
+
if (!heading) continue;
|
|
128
|
+
const explicit = heading.text.match(/\{#([^}]+)\}\s*$/);
|
|
129
|
+
if (explicit) {
|
|
130
|
+
slugs.add(explicit[1]);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const slug = heading.text
|
|
134
|
+
.toLowerCase()
|
|
135
|
+
.replace(/<[^>]*>/g, '')
|
|
136
|
+
.replace(/[^\p{L}\p{N}_\-\s]/gu, '')
|
|
137
|
+
.replace(/\s/g, '-');
|
|
138
|
+
const count = counts.get(slug) ?? 0;
|
|
139
|
+
slugs.add(count ? `${slug}-${count}` : slug);
|
|
140
|
+
counts.set(slug, count + 1);
|
|
141
|
+
}
|
|
142
|
+
return slugs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function markdownLinks(markdown) {
|
|
146
|
+
const lines = markdownLines(markdown);
|
|
147
|
+
const references = new Map();
|
|
148
|
+
const links = [];
|
|
149
|
+
for (const { text, line } of lines) {
|
|
150
|
+
const declaration = text.match(/^\s*\[([^\]]+)\]:\s*(<[^>]+>|\S+)/);
|
|
151
|
+
if (declaration)
|
|
152
|
+
references.set(declaration[1].trim().toLowerCase(), declaration[2].replace(/^<|>$/g, ''));
|
|
153
|
+
const inline = /!?\[[^\]]*\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^\n]*["'])?\s*\)/g;
|
|
154
|
+
for (const match of text.matchAll(inline))
|
|
155
|
+
links.push({ destination: match[1].replace(/^<|>$/g, ''), line });
|
|
156
|
+
const referenced = /!?\[([^\]]+)\]\[([^\]]*)\]/g;
|
|
157
|
+
for (const match of text.matchAll(referenced))
|
|
158
|
+
links.push({ reference: (match[2] || match[1]).trim().toLowerCase(), line });
|
|
159
|
+
}
|
|
160
|
+
return links.map(link =>
|
|
161
|
+
link.reference ? { ...link, destination: references.get(link.reference) } : link,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function checkLinks(directory, records, root, report) {
|
|
166
|
+
const targets = new Map();
|
|
167
|
+
for (const [file, markdown] of records) {
|
|
168
|
+
for (const link of markdownLinks(markdown)) {
|
|
169
|
+
if (link.reference && !link.destination) {
|
|
170
|
+
report(
|
|
171
|
+
'link-reference',
|
|
172
|
+
file,
|
|
173
|
+
link.line,
|
|
174
|
+
`Undefined Markdown link reference: ${link.reference}.`,
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const destination = link.destination;
|
|
179
|
+
if (!destination || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(destination)) continue;
|
|
180
|
+
let path;
|
|
181
|
+
let fragment;
|
|
182
|
+
try {
|
|
183
|
+
const hash = destination.indexOf('#');
|
|
184
|
+
path = decodeURIComponent(
|
|
185
|
+
(hash < 0 ? destination : destination.slice(0, hash)).split('?')[0],
|
|
186
|
+
);
|
|
187
|
+
fragment = hash < 0 ? '' : decodeURIComponent(destination.slice(hash + 1));
|
|
188
|
+
} catch {
|
|
189
|
+
report('link-format', file, link.line, `Invalid encoded local link: ${destination}.`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const target = path
|
|
193
|
+
? resolve(
|
|
194
|
+
path.startsWith('/') ? root : dirname(resolve(directory, file)),
|
|
195
|
+
path.replace(/^\//, ''),
|
|
196
|
+
)
|
|
197
|
+
: resolve(directory, file);
|
|
198
|
+
if (!(await exists(target))) {
|
|
199
|
+
report('link-target', file, link.line, `Missing local link target: ${destination}.`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!fragment || extname(target) !== '.md') continue;
|
|
203
|
+
if (!targets.has(target)) targets.set(target, headingIds(await readFile(target, 'utf8')));
|
|
204
|
+
if (!targets.get(target).has(fragment)) {
|
|
205
|
+
report(
|
|
206
|
+
'link-fragment',
|
|
207
|
+
file,
|
|
208
|
+
link.line,
|
|
209
|
+
`Missing Markdown heading fragment: ${destination}.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function auditPlanning(
|
|
217
|
+
directory,
|
|
218
|
+
{ lightweight = false, root = resolve(directory, '../..') } = {},
|
|
219
|
+
) {
|
|
220
|
+
directory = resolve(directory);
|
|
221
|
+
const diagnostics = [];
|
|
222
|
+
const report = (code, file, line, message, id) =>
|
|
223
|
+
diagnostics.push({ code, file, line, ...(id ? { id } : {}), message });
|
|
224
|
+
const records = new Map();
|
|
225
|
+
for (const file of await planningFiles(directory))
|
|
226
|
+
records.set(file, await readFile(resolve(directory, file), 'utf8'));
|
|
227
|
+
if (!lightweight) {
|
|
228
|
+
for (const file of CORE) {
|
|
229
|
+
if (!records.has(file))
|
|
230
|
+
report('missing-record', file, 1, 'Required complete-baseline record is missing.');
|
|
231
|
+
}
|
|
232
|
+
if (![...records.keys()].some(file => /^decisions\/[^/]+\.md$/.test(file)))
|
|
233
|
+
report('missing-adr', 'decisions/', 1, 'A complete baseline requires a suite boundary ADR.');
|
|
234
|
+
}
|
|
235
|
+
const definitions = new Map();
|
|
236
|
+
let wbs;
|
|
237
|
+
for (const [prefix, file] of OWNERS) {
|
|
238
|
+
if (!records.has(file)) continue;
|
|
239
|
+
try {
|
|
240
|
+
let declared;
|
|
241
|
+
if (prefix === 'WBS-') {
|
|
242
|
+
wbs = parseWbs(records.get(file), { file });
|
|
243
|
+
declared = new Map(wbs.tasks.map(task => [task.id, { ...task, file, kind: 'WBS' }]));
|
|
244
|
+
} else {
|
|
245
|
+
declared = parseDefinitions(records.get(file), prefix, { file });
|
|
246
|
+
}
|
|
247
|
+
for (const [id, definition] of declared) definitions.set(id, { ...definition, file });
|
|
248
|
+
if (!lightweight && !declared.size) {
|
|
249
|
+
report(
|
|
250
|
+
'missing-definition',
|
|
251
|
+
file,
|
|
252
|
+
1,
|
|
253
|
+
`No formal ${prefix.slice(0, -1)} definitions found.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
report('declaration', file, 1, error.message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const [file, markdown] of records) {
|
|
261
|
+
for (const { text, line } of markdownLines(markdown)) {
|
|
262
|
+
for (const prefix of OWNERS.keys()) {
|
|
263
|
+
for (const id of findIdentifiers(text, prefix)) {
|
|
264
|
+
if (!definitions.has(id)) {
|
|
265
|
+
report(
|
|
266
|
+
'undefined-reference',
|
|
267
|
+
file,
|
|
268
|
+
line,
|
|
269
|
+
`No unique formal definition in ${OWNERS.get(prefix)}.`,
|
|
270
|
+
id,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (wbs) {
|
|
278
|
+
const progress = records.get('progress.md');
|
|
279
|
+
if (progress !== undefined) {
|
|
280
|
+
try {
|
|
281
|
+
const { rows, locations } = parseProgress(progress, { file: 'progress.md' });
|
|
282
|
+
for (const task of wbs.tasks) {
|
|
283
|
+
if (!rows.has(task.id)) {
|
|
284
|
+
report('missing-progress', 'progress.md', 1, 'WBS task has no progress row.', task.id);
|
|
285
|
+
} else if (
|
|
286
|
+
(task.status || task.deferred) &&
|
|
287
|
+
rows.get(task.id) !== (task.status ?? 'deferred')
|
|
288
|
+
) {
|
|
289
|
+
report(
|
|
290
|
+
'status-conflict',
|
|
291
|
+
'progress.md',
|
|
292
|
+
locations?.get(task.id)?.line ?? 1,
|
|
293
|
+
`Progress contradicts explicit WBS status ${task.status ?? 'deferred'}.`,
|
|
294
|
+
task.id,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (['implementation-complete', 'verified'].includes(rows.get(task.id))) {
|
|
298
|
+
for (const dependency of task.dependencyIds) {
|
|
299
|
+
if (
|
|
300
|
+
['not-started', 'in-progress', 'blocked', 'deferred'].includes(rows.get(dependency))
|
|
301
|
+
) {
|
|
302
|
+
report(
|
|
303
|
+
'dependency-state',
|
|
304
|
+
'progress.md',
|
|
305
|
+
locations?.get(task.id)?.line ?? 1,
|
|
306
|
+
`Completed task still has an unfinished recorded prerequisite: ${dependency}.`,
|
|
307
|
+
task.id,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const id of rows.keys()) {
|
|
314
|
+
if (!definitions.has(id)) {
|
|
315
|
+
report(
|
|
316
|
+
'unknown-progress',
|
|
317
|
+
'progress.md',
|
|
318
|
+
locations?.get(id)?.line ?? 1,
|
|
319
|
+
'Progress row has no WBS definition.',
|
|
320
|
+
id,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
report('progress', 'progress.md', 1, error.message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} else if (records.has('progress.md') && !records.has('pdp-wbs.md')) {
|
|
329
|
+
report('missing-owner', 'progress.md', 1, 'A progress register requires pdp-wbs.md.');
|
|
330
|
+
}
|
|
331
|
+
if (!lightweight) {
|
|
332
|
+
const edges = collectMappings(records, definitions);
|
|
333
|
+
const incoming = new Set([...edges.values()].flatMap(values => [...values]));
|
|
334
|
+
for (const [id, definition] of definitions) {
|
|
335
|
+
if (definition.status === 'deferred' || definition.deferred) continue;
|
|
336
|
+
if (!id.startsWith('PRD-') && !incoming.has(id)) {
|
|
337
|
+
report(
|
|
338
|
+
'traceability-incoming',
|
|
339
|
+
definition.file,
|
|
340
|
+
definition.line,
|
|
341
|
+
'No explicit exact-ID mapping from the preceding authority kind.',
|
|
342
|
+
id,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (!id.startsWith('ATP-') && !edges.get(id).size) {
|
|
346
|
+
report(
|
|
347
|
+
'traceability-outgoing',
|
|
348
|
+
definition.file,
|
|
349
|
+
definition.line,
|
|
350
|
+
'No explicit exact-ID mapping to the next authority kind.',
|
|
351
|
+
id,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await checkLinks(directory, records, resolve(root), report);
|
|
357
|
+
diagnostics.sort(
|
|
358
|
+
(a, b) =>
|
|
359
|
+
a.file.localeCompare(b.file, 'en') ||
|
|
360
|
+
a.line - b.line ||
|
|
361
|
+
a.code.localeCompare(b.code, 'en') ||
|
|
362
|
+
(a.id ?? '').localeCompare(b.id ?? '', 'en'),
|
|
363
|
+
);
|
|
364
|
+
return {
|
|
365
|
+
mode: lightweight ? 'lightweight' : 'complete',
|
|
366
|
+
ok: diagnostics.length === 0,
|
|
367
|
+
files: records.size,
|
|
368
|
+
definitions: definitions.size,
|
|
369
|
+
coverage: lightweight
|
|
370
|
+
? 'Present owners, exact references, progress, and local links; full-chain coverage not checked.'
|
|
371
|
+
: 'Complete-baseline structure and explicit exact-ID chain.',
|
|
372
|
+
omittedRecords: CORE.filter(file => !records.has(file)),
|
|
373
|
+
diagnostics,
|
|
374
|
+
limitations: LIMITS,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function main(args) {
|
|
379
|
+
let target;
|
|
380
|
+
let lightweight = false;
|
|
381
|
+
let format = 'text';
|
|
382
|
+
for (let index = 0; index < args.length; index++) {
|
|
383
|
+
const value = args[index];
|
|
384
|
+
if (value === '--lightweight') {
|
|
385
|
+
lightweight = true;
|
|
386
|
+
} else if (value === '--format' && ['text', 'json'].includes(args[index + 1])) {
|
|
387
|
+
format = args[++index];
|
|
388
|
+
} else if (!value.startsWith('-') && !target) {
|
|
389
|
+
target = value;
|
|
390
|
+
} else {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Unknown argument: ${value}. Usage: spec:check <suite-or-directory> [--lightweight] [--format text|json]`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!target)
|
|
397
|
+
throw new Error('Usage: spec:check <suite-or-directory> [--lightweight] [--format text|json]');
|
|
398
|
+
const root = process.cwd();
|
|
399
|
+
const directory = (await exists(resolve(root, target)))
|
|
400
|
+
? resolve(root, target)
|
|
401
|
+
: resolve(root, 'repo-specs', target);
|
|
402
|
+
const result = await auditPlanning(directory, { lightweight, root });
|
|
403
|
+
if (format === 'json') {
|
|
404
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
405
|
+
} else {
|
|
406
|
+
process.stdout.write(
|
|
407
|
+
`${relative(root, directory)}: ${result.mode} planning structure ${result.ok ? 'passed' : 'failed'} (${result.definitions} definitions).\n`,
|
|
408
|
+
);
|
|
409
|
+
for (const diagnostic of result.diagnostics) {
|
|
410
|
+
process.stdout.write(
|
|
411
|
+
`${diagnostic.file}:${diagnostic.line} [${diagnostic.code}]${diagnostic.id ? ` ${diagnostic.id}` : ''} ${diagnostic.message}\n`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
process.stdout.write(`${result.coverage}\n`);
|
|
415
|
+
if (result.omittedRecords.length)
|
|
416
|
+
process.stdout.write(`Absent core records: ${result.omittedRecords.join(', ')}.\n`);
|
|
417
|
+
process.stdout.write(`${result.limitations}\n`);
|
|
418
|
+
}
|
|
419
|
+
if (!result.ok) process.exitCode = 1;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
423
|
+
main(process.argv.slice(2)).catch(error => {
|
|
424
|
+
process.stderr.write(`${error.message}\n`);
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
});
|
|
427
|
+
}
|