@forgeailab/spark 0.4.0 → 0.4.2
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/package.json +2 -2
- package/src/cli.ts +4 -0
- package/src/commands/add.ts +13 -5
- package/src/commands/check.ts +4 -4
- package/src/commands/list.ts +2 -2
- package/src/commands/status.ts +32 -0
- package/src/commands/validate.ts +281 -0
- package/src/config.ts +1 -1
- package/src/internal/board.ts +6 -6
- package/src/internal/skill-utils.ts +1 -1
- package/src/internal/state.ts +3 -3
- package/src/io/board.ts +3 -3
- package/src/io/deps.ts +1 -1
- package/src/io/env.ts +2 -2
- package/src/io/files.ts +17 -3
- package/src/io/registry.ts +12 -6
- package/src/io/skills.ts +2 -2
- package/src/io/state.ts +3 -3
- package/src/resolver.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeailab/spark",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "CLI for managing feature packs in an spark-scaffolded project.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"typecheck": "tsc --noEmit"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@forgeailab/spark-schema": "0.4.
|
|
26
|
+
"@forgeailab/spark-schema": "0.4.2",
|
|
27
27
|
"@clack/prompts": "latest",
|
|
28
28
|
"citty": "latest",
|
|
29
29
|
"picocolors": "latest",
|
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { checkCommand } from './commands/check.ts';
|
|
|
5
5
|
import { infoCommand } from './commands/info.ts';
|
|
6
6
|
import { listCommand } from './commands/list.ts';
|
|
7
7
|
import { presetCommand } from './commands/preset.ts';
|
|
8
|
+
import { statusCommand } from './commands/status.ts';
|
|
9
|
+
import { validateCommand } from './commands/validate.ts';
|
|
8
10
|
|
|
9
11
|
const subCommands = {
|
|
10
12
|
list: listCommand,
|
|
@@ -12,6 +14,8 @@ const subCommands = {
|
|
|
12
14
|
check: checkCommand,
|
|
13
15
|
add: addCommand,
|
|
14
16
|
preset: presetCommand,
|
|
17
|
+
status: statusCommand,
|
|
18
|
+
validate: validateCommand,
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
const mainCommand = defineCommand({
|
package/src/commands/add.ts
CHANGED
|
@@ -100,10 +100,10 @@ function renderPlan(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (runtimeDependencies.length > 0) {
|
|
103
|
-
lines.push(`runtime deps: ${[...new Set(runtimeDependencies)].
|
|
103
|
+
lines.push(`runtime deps: ${[...new Set(runtimeDependencies)].toSorted().join(', ')}`);
|
|
104
104
|
}
|
|
105
105
|
if (devDependencies.length > 0) {
|
|
106
|
-
lines.push(`dev deps: ${[...new Set(devDependencies)].
|
|
106
|
+
lines.push(`dev deps: ${[...new Set(devDependencies)].toSorted().join(', ')}`);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
return lines.join('\n');
|
|
@@ -181,7 +181,7 @@ function stateEntryForPack(
|
|
|
181
181
|
return {
|
|
182
182
|
name: packName,
|
|
183
183
|
version,
|
|
184
|
-
files: [...new Set(fileTargets)].
|
|
184
|
+
files: [...new Set(fileTargets)].toSorted(),
|
|
185
185
|
appended_blocks: fileRecords
|
|
186
186
|
.filter((record) => record.marker)
|
|
187
187
|
.map((record) => ({
|
|
@@ -189,8 +189,8 @@ function stateEntryForPack(
|
|
|
189
189
|
marker: record.marker ?? '',
|
|
190
190
|
content_hash: record.contentHash,
|
|
191
191
|
})),
|
|
192
|
-
env: [...new Set(envVars)].
|
|
193
|
-
tasks: [...new Set(taskIds)].
|
|
192
|
+
env: [...new Set(envVars)].toSorted(),
|
|
193
|
+
tasks: [...new Set(taskIds)].toSorted(),
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
|
|
@@ -236,6 +236,14 @@ export async function runAdd(requestedPacks: readonly string[], options: AddOpti
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
if (!options.yes) {
|
|
239
|
+
if (!process.stdin.isTTY) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'spark add needs interactive confirmation but stdin is not a TTY. ' +
|
|
242
|
+
'Re-run with --yes. Non-interactive callers (the /scaffold and /pack-add flows, ' +
|
|
243
|
+
'CI, or any agent-spawned process) must pass --yes — they gate approval before invoking add.',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
239
247
|
const accepted = await confirm({
|
|
240
248
|
message: `Install ${plan.packs.map((pack) => pack.name).join(', ')}?`,
|
|
241
249
|
initialValue: false,
|
package/src/commands/check.ts
CHANGED
|
@@ -33,7 +33,7 @@ async function fileExists(path: string): Promise<boolean> {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function escapeRegex(value: string): string {
|
|
36
|
-
return value.
|
|
36
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function hasEnvVar(content: string, key: string): boolean {
|
|
@@ -59,9 +59,9 @@ export async function runCheck(
|
|
|
59
59
|
const state = await readState(projectRoot);
|
|
60
60
|
const recordedFiles = [
|
|
61
61
|
...new Set(state.installed_packs.flatMap((pack) => pack.files)),
|
|
62
|
-
].
|
|
63
|
-
const recordedEnv = [...new Set(state.installed_packs.flatMap((pack) => pack.env))].
|
|
64
|
-
const recordedTasks = [...new Set(state.installed_packs.flatMap((pack) => pack.tasks))].
|
|
62
|
+
].toSorted();
|
|
63
|
+
const recordedEnv = [...new Set(state.installed_packs.flatMap((pack) => pack.env))].toSorted();
|
|
64
|
+
const recordedTasks = [...new Set(state.installed_packs.flatMap((pack) => pack.tasks))].toSorted();
|
|
65
65
|
|
|
66
66
|
const missingFiles: string[] = [];
|
|
67
67
|
for (const file of recordedFiles) {
|
package/src/commands/list.ts
CHANGED
|
@@ -54,12 +54,12 @@ export async function runList(projectRoot = process.cwd(), output: ListOutput =
|
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
for (const [category, names] of [...byCategory.entries()].
|
|
57
|
+
for (const [category, names] of [...byCategory.entries()].toSorted(([left], [right]) =>
|
|
58
58
|
left.localeCompare(right),
|
|
59
59
|
)) {
|
|
60
60
|
output.log(pc.bold(category));
|
|
61
61
|
output.log('pack status scaffold');
|
|
62
|
-
for (const name of names.
|
|
62
|
+
for (const name of names.toSorted()) {
|
|
63
63
|
const status = installed.has(name) ? pc.green('installed') : 'available';
|
|
64
64
|
const annotation = scaffoldAnnotation(registry, config.template, name);
|
|
65
65
|
output.log(`${name.padEnd(20)} ${status.padEnd(11)} ${annotation}`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { readAllChangeTasks, renderBuildStatus } from '../io/board.ts';
|
|
3
|
+
import type { AggregatedTask } from '../io/board.ts';
|
|
4
|
+
|
|
5
|
+
export async function runStatus(
|
|
6
|
+
projectRoot = process.cwd(),
|
|
7
|
+
opts: { change?: string } = {},
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
const all: AggregatedTask[] = await readAllChangeTasks(projectRoot);
|
|
10
|
+
const filtered = opts.change ? all.filter((t) => t.changeId === opts.change) : all;
|
|
11
|
+
const view = renderBuildStatus(filtered);
|
|
12
|
+
console.log(view);
|
|
13
|
+
return view;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const statusCommand = defineCommand({
|
|
17
|
+
meta: {
|
|
18
|
+
name: 'status',
|
|
19
|
+
description: 'Render the build-status view from tasks.md across active changes',
|
|
20
|
+
},
|
|
21
|
+
args: {
|
|
22
|
+
change: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Limit to one change id',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
async run({ args }) {
|
|
28
|
+
await runStatus(process.cwd(), {
|
|
29
|
+
change: typeof args.change === 'string' ? args.change : undefined,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { parseTasksMarkdown } from '../io/board.ts';
|
|
6
|
+
|
|
7
|
+
export type Violation = { file: string; line: number; message: string };
|
|
8
|
+
|
|
9
|
+
async function dirExists(p: string): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
const s = await stat(p);
|
|
12
|
+
return s.isDirectory();
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function collectSpecFiles(dir: string): Promise<string[]> {
|
|
19
|
+
const results: string[] = [];
|
|
20
|
+
async function walk(current: string): Promise<void> {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const full = join(current, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
await walk(full);
|
|
31
|
+
} else if (entry.isFile() && entry.name === 'spec.md') {
|
|
32
|
+
results.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await walk(dir);
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isUnderArchive(filePath: string): boolean {
|
|
41
|
+
return filePath.split('/').includes('archive');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DELTA_HEADERS = new Set([
|
|
45
|
+
'## ADDED Requirements',
|
|
46
|
+
'## MODIFIED Requirements',
|
|
47
|
+
'## REMOVED Requirements',
|
|
48
|
+
'## RENAMED Requirements',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
function skipFrontmatterLines(lines: string[]): number {
|
|
52
|
+
if (lines.length === 0) return 0;
|
|
53
|
+
if (lines[0].trim() !== '---') return 0;
|
|
54
|
+
for (let i = 1; i < lines.length; i++) {
|
|
55
|
+
if (lines[i].trim() === '---') return i + 1;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkDeltaHeader(relFile: string, lines: string[]): Violation | null {
|
|
61
|
+
const start = skipFrontmatterLines(lines);
|
|
62
|
+
for (let i = start; i < lines.length; i++) {
|
|
63
|
+
const trimmed = lines[i].trim();
|
|
64
|
+
if (trimmed.length === 0) continue;
|
|
65
|
+
if (!DELTA_HEADERS.has(trimmed)) {
|
|
66
|
+
return {
|
|
67
|
+
file: relFile,
|
|
68
|
+
line: i + 1,
|
|
69
|
+
message: `expected one of "## ADDED|MODIFIED|REMOVED|RENAMED Requirements" as first non-empty line, got: "${trimmed}"`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return { file: relFile, line: 1, message: 'spec file is empty or has no content after frontmatter' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function checkRequirementsAndScenarios(relFile: string, lines: string[]): Violation[] {
|
|
78
|
+
const violations: Violation[] = [];
|
|
79
|
+
|
|
80
|
+
let reqLine = -1;
|
|
81
|
+
let hasDescription = false;
|
|
82
|
+
let hasScenario = false;
|
|
83
|
+
let scenLine = -1;
|
|
84
|
+
let scenHasWhen = false;
|
|
85
|
+
let scenHasThen = false;
|
|
86
|
+
|
|
87
|
+
function finishScenario(): void {
|
|
88
|
+
if (scenLine === -1) return;
|
|
89
|
+
if (!scenHasWhen || !scenHasThen) {
|
|
90
|
+
violations.push({
|
|
91
|
+
file: relFile,
|
|
92
|
+
line: scenLine + 1,
|
|
93
|
+
message: 'scenario must have at least one **WHEN** and one **THEN** bullet',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
scenLine = -1;
|
|
97
|
+
scenHasWhen = false;
|
|
98
|
+
scenHasThen = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function finishRequirement(): void {
|
|
102
|
+
if (reqLine === -1) return;
|
|
103
|
+
finishScenario();
|
|
104
|
+
if (!hasDescription || !hasScenario) {
|
|
105
|
+
violations.push({
|
|
106
|
+
file: relFile,
|
|
107
|
+
line: reqLine + 1,
|
|
108
|
+
message:
|
|
109
|
+
'requirement must have at least one descriptive line and one "#### Scenario:" before the next requirement or EOF',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
reqLine = -1;
|
|
113
|
+
hasDescription = false;
|
|
114
|
+
hasScenario = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
const line = lines[i];
|
|
119
|
+
const trimmed = line.trim();
|
|
120
|
+
|
|
121
|
+
if (/^#{4}\s+/.test(trimmed)) {
|
|
122
|
+
if (/^#{4}\s+Scenario:/i.test(trimmed)) {
|
|
123
|
+
finishScenario();
|
|
124
|
+
if (reqLine !== -1) hasScenario = true;
|
|
125
|
+
scenLine = i;
|
|
126
|
+
scenHasWhen = false;
|
|
127
|
+
scenHasThen = false;
|
|
128
|
+
} else {
|
|
129
|
+
finishScenario();
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (/^#{3}\s+/.test(trimmed)) {
|
|
135
|
+
finishRequirement();
|
|
136
|
+
if (/^#{3}\s+Requirement:/i.test(trimmed)) {
|
|
137
|
+
reqLine = i;
|
|
138
|
+
hasDescription = false;
|
|
139
|
+
hasScenario = false;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (/^#{2}\s+/.test(trimmed) || /^#\s+/.test(trimmed)) {
|
|
145
|
+
finishRequirement();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (scenLine !== -1 && /^\s*[-*]\s+/.test(line)) {
|
|
150
|
+
if (line.includes('**WHEN**')) scenHasWhen = true;
|
|
151
|
+
if (line.includes('**THEN**')) scenHasThen = true;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (reqLine !== -1 && scenLine === -1 && trimmed.length > 0) {
|
|
156
|
+
hasDescription = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
finishRequirement();
|
|
161
|
+
return violations;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function runValidate(
|
|
165
|
+
targetPath = 'docs/spark',
|
|
166
|
+
projectRoot = process.cwd(),
|
|
167
|
+
): Promise<Violation[]> {
|
|
168
|
+
const root = join(projectRoot, targetPath);
|
|
169
|
+
|
|
170
|
+
if (!(await dirExists(root))) {
|
|
171
|
+
return [{ file: targetPath, line: 1, message: `workspace path does not exist: ${root}` }];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const violations: Violation[] = [];
|
|
175
|
+
const changesDir = join(root, 'changes');
|
|
176
|
+
|
|
177
|
+
async function getChangeIds(): Promise<string[]> {
|
|
178
|
+
if (!(await dirExists(changesDir))) return [];
|
|
179
|
+
try {
|
|
180
|
+
const dirents = await readdir(changesDir, { withFileTypes: true });
|
|
181
|
+
return dirents.filter((d) => d.isDirectory() && d.name !== 'archive').map((d) => d.name);
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const changeIds = await getChangeIds();
|
|
188
|
+
|
|
189
|
+
const deltaSpecFiles: string[] = [];
|
|
190
|
+
for (const changeId of changeIds) {
|
|
191
|
+
const specsDir = join(changesDir, changeId, 'specs');
|
|
192
|
+
if (await dirExists(specsDir)) {
|
|
193
|
+
for (const f of await collectSpecFiles(specsDir)) {
|
|
194
|
+
if (!isUnderArchive(f)) deltaSpecFiles.push(f);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const truthSpecFiles: string[] = [];
|
|
200
|
+
const truthSpecDir = join(root, 'specs');
|
|
201
|
+
if (await dirExists(truthSpecDir)) {
|
|
202
|
+
for (const f of await collectSpecFiles(truthSpecDir)) {
|
|
203
|
+
if (!isUnderArchive(f)) truthSpecFiles.push(f);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const tasksFiles: string[] = [];
|
|
208
|
+
for (const changeId of changeIds) {
|
|
209
|
+
const tasksPath = join(changesDir, changeId, 'tasks.md');
|
|
210
|
+
try {
|
|
211
|
+
await stat(tasksPath);
|
|
212
|
+
tasksFiles.push(tasksPath);
|
|
213
|
+
} catch {
|
|
214
|
+
// no tasks.md — not an error
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const file of deltaSpecFiles) {
|
|
219
|
+
const lines = (await readFile(file, 'utf8')).split('\n');
|
|
220
|
+
const v = checkDeltaHeader(relative(projectRoot, file), lines);
|
|
221
|
+
if (v) violations.push(v);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const file of [...deltaSpecFiles, ...truthSpecFiles]) {
|
|
225
|
+
const lines = (await readFile(file, 'utf8')).split('\n');
|
|
226
|
+
violations.push(...checkRequirementsAndScenarios(relative(projectRoot, file), lines));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const tasksPath of tasksFiles) {
|
|
230
|
+
let raw: string;
|
|
231
|
+
try {
|
|
232
|
+
raw = await readFile(tasksPath, 'utf8');
|
|
233
|
+
} catch (err) {
|
|
234
|
+
violations.push({
|
|
235
|
+
file: relative(projectRoot, tasksPath),
|
|
236
|
+
line: 1,
|
|
237
|
+
message: `could not read tasks file: ${String(err)}`,
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
parseTasksMarkdown(tasksPath, raw);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const msg = String(err instanceof Error ? err.message : err);
|
|
245
|
+
const lineMatch = /:(\d+):/.exec(msg);
|
|
246
|
+
violations.push({
|
|
247
|
+
file: relative(projectRoot, tasksPath),
|
|
248
|
+
line: lineMatch ? parseInt(lineMatch[1], 10) : 1,
|
|
249
|
+
message: msg,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return violations;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const validateCommand = defineCommand({
|
|
258
|
+
meta: {
|
|
259
|
+
name: 'validate',
|
|
260
|
+
description: 'Lint the docs/spark spec workspace; exit non-zero on any violation',
|
|
261
|
+
},
|
|
262
|
+
args: {
|
|
263
|
+
path: {
|
|
264
|
+
type: 'positional',
|
|
265
|
+
required: false,
|
|
266
|
+
default: 'docs/spark',
|
|
267
|
+
description: 'Workspace path to validate',
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
async run({ args }) {
|
|
271
|
+
const p = typeof args.path === 'string' && args.path.length > 0 ? args.path : 'docs/spark';
|
|
272
|
+
const v = await runValidate(p);
|
|
273
|
+
if (v.length > 0) {
|
|
274
|
+
for (const x of v) console.error(pc.red(`${x.file}:${x.line} ${x.message}`));
|
|
275
|
+
console.error(pc.red(`validate: ${v.length} problem(s) found`));
|
|
276
|
+
process.exitCode = 1;
|
|
277
|
+
} else {
|
|
278
|
+
console.log(pc.green('OK: spec workspace is well-formed.'));
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -19,7 +19,7 @@ export async function readConfig(projectRoot: string): Promise<AppSkillsConfig>
|
|
|
19
19
|
raw = await readFile(configPath, 'utf8');
|
|
20
20
|
} catch (error) {
|
|
21
21
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
22
|
-
throw new Error('not in an spark project: missing spark.config.json');
|
|
22
|
+
throw new Error('not in an spark project: missing spark.config.json', { cause: error });
|
|
23
23
|
}
|
|
24
24
|
throw error;
|
|
25
25
|
}
|
package/src/internal/board.ts
CHANGED
|
@@ -80,7 +80,7 @@ function assertStatus(status: BoardTaskStatus, path: string): BoardTaskStatus {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function escapeRegex(value: string): string {
|
|
83
|
-
return value.
|
|
83
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -242,7 +242,7 @@ export async function readAllChangeTasks(projectRoot: string): Promise<Aggregate
|
|
|
242
242
|
changeIds = dirents
|
|
243
243
|
.filter((d) => d.isDirectory() && d.name !== 'archive')
|
|
244
244
|
.map((d) => d.name)
|
|
245
|
-
.
|
|
245
|
+
.toSorted();
|
|
246
246
|
} catch (error) {
|
|
247
247
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
248
248
|
throw error;
|
|
@@ -288,7 +288,7 @@ export function renderBuildStatus(tasks: readonly AggregatedTask[]): string {
|
|
|
288
288
|
function renderGroup(label: string, group: readonly AggregatedTask[]): void {
|
|
289
289
|
if (group.length === 0) return;
|
|
290
290
|
lines.push(`\n### ${label}`);
|
|
291
|
-
const sorted = [...group].
|
|
291
|
+
const sorted = [...group].toSorted((a, b) =>
|
|
292
292
|
a.changeId === b.changeId ? a.id.localeCompare(b.id) : a.changeId.localeCompare(b.changeId),
|
|
293
293
|
);
|
|
294
294
|
for (const task of sorted) {
|
|
@@ -328,8 +328,8 @@ function buildFrontmatter(): string {
|
|
|
328
328
|
function generatedTaskId(packName: string, index: number): string {
|
|
329
329
|
const prefix = packName
|
|
330
330
|
.toUpperCase()
|
|
331
|
-
.
|
|
332
|
-
.
|
|
331
|
+
.replaceAll(/[^A-Z0-9]+/g, '-')
|
|
332
|
+
.replaceAll(/^-+|-+$/g, '');
|
|
333
333
|
return `${prefix || 'PACK'}-${String(index + 1).padStart(3, '0')}`;
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -354,7 +354,7 @@ function formatSeedTask(task: Required<SeedTask>, packName: string): string {
|
|
|
354
354
|
const lines = [`- [${statusMarkers[task.status]}] ${task.id}: ${task.title}`];
|
|
355
355
|
lines.push(' - Status: Clarifying');
|
|
356
356
|
lines.push(` - requires_pack: ${packName}`);
|
|
357
|
-
const description = task.description.
|
|
357
|
+
const description = task.description.replaceAll(/\r\n/g, '\n').replace(/\n$/u, '');
|
|
358
358
|
if (description.trim().length > 0) {
|
|
359
359
|
lines.push(...description.split('\n').map((l) => ` ${l}`));
|
|
360
360
|
}
|
|
@@ -34,7 +34,7 @@ function rawBlocks(frontmatter: Record<string, unknown>): FrontmatterBlockMap |
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function splitFrontmatter(source: string): { frontmatter: string; body: string } {
|
|
37
|
-
const normalized = source.
|
|
37
|
+
const normalized = source.replaceAll(/\r\n/g, '\n');
|
|
38
38
|
const lines = normalized.split('\n');
|
|
39
39
|
|
|
40
40
|
if (lines[0] !== '---') {
|
package/src/internal/state.ts
CHANGED
|
@@ -13,14 +13,14 @@ function initialState(): StateFile {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function uniqueSorted(values: readonly string[]): string[] {
|
|
16
|
-
return [...new Set(values)].
|
|
16
|
+
return [...new Set(values)].toSorted();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function normalizeInstalledPack(pack: StateInstalledPack): StateInstalledPack {
|
|
20
20
|
return {
|
|
21
21
|
...pack,
|
|
22
22
|
files: uniqueSorted(pack.files),
|
|
23
|
-
appended_blocks: [...pack.appended_blocks].
|
|
23
|
+
appended_blocks: [...pack.appended_blocks].toSorted((left, right) =>
|
|
24
24
|
`${left.to}:${left.marker}`.localeCompare(`${right.to}:${right.marker}`),
|
|
25
25
|
),
|
|
26
26
|
env: uniqueSorted(pack.env),
|
|
@@ -35,7 +35,7 @@ function normalizeState(state: StateFile): StateFile {
|
|
|
35
35
|
schema_version: 1,
|
|
36
36
|
installed_packs: [...parsed.installed_packs]
|
|
37
37
|
.map(normalizeInstalledPack)
|
|
38
|
-
.
|
|
38
|
+
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
package/src/io/board.ts
CHANGED
|
@@ -144,7 +144,7 @@ export async function seedBoardTasks(
|
|
|
144
144
|
packName,
|
|
145
145
|
tasks
|
|
146
146
|
.filter((task) => missing.has(task.id))
|
|
147
|
-
.
|
|
147
|
+
.toSorted((left, right) => left.id.localeCompare(right.id))
|
|
148
148
|
.map((task) => ({
|
|
149
149
|
id: task.id,
|
|
150
150
|
title: task.title,
|
|
@@ -154,7 +154,7 @@ export async function seedBoardTasks(
|
|
|
154
154
|
);
|
|
155
155
|
|
|
156
156
|
const missingAfter = new Set(await missingBoardTasks(projectRoot, taskIds));
|
|
157
|
-
return missingBefore.filter((taskId) => !missingAfter.has(taskId)).
|
|
157
|
+
return missingBefore.filter((taskId) => !missingAfter.has(taskId)).toSorted();
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
export async function missingBoardTasks(
|
|
@@ -163,5 +163,5 @@ export async function missingBoardTasks(
|
|
|
163
163
|
): Promise<string[]> {
|
|
164
164
|
if (taskIds.length === 0) return [];
|
|
165
165
|
const existingIds = new Set((await readAllChangeTasks(projectRoot)).map((task) => task.id));
|
|
166
|
-
return taskIds.filter((id) => !existingIds.has(id)).
|
|
166
|
+
return taskIds.filter((id) => !existingIds.has(id)).toSorted();
|
|
167
167
|
}
|
package/src/io/deps.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type DependencyCommand = {
|
|
|
6
6
|
export type DependencyRunner = (command: DependencyCommand) => Promise<void>;
|
|
7
7
|
|
|
8
8
|
function uniqueSorted(values: readonly string[]): string[] {
|
|
9
|
-
return [...new Set(values)].
|
|
9
|
+
return [...new Set(values)].toSorted();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const defaultDependencyRunner: DependencyRunner = async ({ args, cwd }) => {
|
package/src/io/env.ts
CHANGED
|
@@ -18,7 +18,7 @@ async function readExisting(path: string): Promise<string | undefined> {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function escapeRegex(value: string): string {
|
|
21
|
-
return value.
|
|
21
|
+
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function hasEnvVar(content: string, key: string): boolean {
|
|
@@ -51,7 +51,7 @@ export async function appendEnvVars(
|
|
|
51
51
|
projectRoot: string,
|
|
52
52
|
vars: readonly string[],
|
|
53
53
|
): Promise<EnvApplyResult[]> {
|
|
54
|
-
const uniqueVars = [...new Set(vars)].
|
|
54
|
+
const uniqueVars = [...new Set(vars)].toSorted();
|
|
55
55
|
const results: EnvApplyResult[] = [];
|
|
56
56
|
|
|
57
57
|
for (const file of ['.env.example', '.env.local']) {
|
package/src/io/files.ts
CHANGED
|
@@ -101,7 +101,7 @@ function sortJson(value: unknown): unknown {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const sorted: Record<string, unknown> = {};
|
|
104
|
-
for (const key of Object.keys(value).
|
|
104
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
105
105
|
sorted[key] = sortJson(value[key]);
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -119,11 +119,25 @@ function lookupTemplateValue(config: AppSkillsConfig, key: string): string {
|
|
|
119
119
|
current = current[segment];
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
if (current === undefined || current === null) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
switch (typeof current) {
|
|
127
|
+
case 'string':
|
|
128
|
+
return current;
|
|
129
|
+
case 'number':
|
|
130
|
+
case 'boolean':
|
|
131
|
+
case 'bigint':
|
|
132
|
+
case 'symbol':
|
|
133
|
+
return String(current);
|
|
134
|
+
default:
|
|
135
|
+
return JSON.stringify(current) ?? '';
|
|
136
|
+
}
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
export function renderTemplate(template: string, config: AppSkillsConfig): string {
|
|
126
|
-
return template.
|
|
140
|
+
return template.replaceAll(/{{\s*([A-Za-z0-9_.-]+)\s*}}/g, (_match, key: string) =>
|
|
127
141
|
lookupTemplateValue(config, key),
|
|
128
142
|
);
|
|
129
143
|
}
|
package/src/io/registry.ts
CHANGED
|
@@ -43,7 +43,7 @@ async function readChildDirectories(root: string): Promise<string[]> {
|
|
|
43
43
|
return entries
|
|
44
44
|
.filter((entry) => entry.isDirectory())
|
|
45
45
|
.map((entry) => entry.name)
|
|
46
|
-
.
|
|
46
|
+
.toSorted();
|
|
47
47
|
} catch (error) {
|
|
48
48
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
49
49
|
return [];
|
|
@@ -63,9 +63,15 @@ async function readToml(path: string): Promise<string | undefined> {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function unwrapParseResult
|
|
66
|
+
function unwrapParseResult(result: ReturnType<typeof parsePackToml>, path: string): PackManifest;
|
|
67
|
+
function unwrapParseResult(result: ReturnType<typeof parsePresetToml>, path: string): PresetManifest;
|
|
68
|
+
function unwrapParseResult(result: ReturnType<typeof parseTemplateToml>, path: string): TemplateManifest;
|
|
69
|
+
function unwrapParseResult(
|
|
70
|
+
result: ReturnType<typeof parsePackToml> | ReturnType<typeof parsePresetToml> | ReturnType<typeof parseTemplateToml>,
|
|
71
|
+
path: string,
|
|
72
|
+
): PackManifest | PresetManifest | TemplateManifest {
|
|
67
73
|
if (result.ok) {
|
|
68
|
-
return result.data
|
|
74
|
+
return result.data;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
const details = result.error.issues?.length ? `\n${result.error.issues.join('\n')}` : '';
|
|
@@ -84,7 +90,7 @@ async function readTemplates(root: string): Promise<Map<string, TemplateRegistry
|
|
|
84
90
|
continue;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
const manifest = unwrapParseResult
|
|
93
|
+
const manifest = unwrapParseResult(parseTemplateToml(raw), manifestPath);
|
|
88
94
|
if (manifest.name !== dirName) {
|
|
89
95
|
throw new Error(`${manifestPath}: template name "${manifest.name}" must match directory "${dirName}"`);
|
|
90
96
|
}
|
|
@@ -119,7 +125,7 @@ async function readPacks(
|
|
|
119
125
|
continue;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
const manifest = unwrapParseResult
|
|
128
|
+
const manifest = unwrapParseResult(parsePackToml(raw), manifestPath);
|
|
123
129
|
if (manifest.name !== dirName) {
|
|
124
130
|
throw new Error(`${manifestPath}: pack name "${manifest.name}" must match directory "${dirName}"`);
|
|
125
131
|
}
|
|
@@ -155,7 +161,7 @@ async function readPresets(root: string): Promise<Map<string, PresetRegistryEntr
|
|
|
155
161
|
|
|
156
162
|
const file = join(presetsRoot, entry.name);
|
|
157
163
|
const raw = await readFile(file, 'utf8');
|
|
158
|
-
const manifest = unwrapParseResult
|
|
164
|
+
const manifest = unwrapParseResult(parsePresetToml(raw), file);
|
|
159
165
|
const name = manifest.name ?? parsePath(entry.name).name;
|
|
160
166
|
|
|
161
167
|
if (manifest.name && manifest.name !== parsePath(entry.name).name) {
|
package/src/io/skills.ts
CHANGED
|
@@ -24,7 +24,7 @@ async function walkFiles(root: string): Promise<string[]> {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
return files.
|
|
27
|
+
return files.toSorted();
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
async function directoryExists(path: string): Promise<boolean> {
|
|
@@ -124,5 +124,5 @@ export async function copyPackSkills(
|
|
|
124
124
|
written.push(...record.claudeFiles, ...record.codexFiles);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
return [...new Set(written)].
|
|
127
|
+
return [...new Set(written)].toSorted();
|
|
128
128
|
}
|
package/src/io/state.ts
CHANGED
|
@@ -13,14 +13,14 @@ export function stateFilePath(projectRoot: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function uniqueSorted(values: readonly string[]): string[] {
|
|
16
|
-
return [...new Set(values)].
|
|
16
|
+
return [...new Set(values)].toSorted();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function normalizeInstalledPack(pack: StateInstalledPack): StateInstalledPack {
|
|
20
20
|
return {
|
|
21
21
|
...pack,
|
|
22
22
|
files: uniqueSorted(pack.files),
|
|
23
|
-
appended_blocks: [...pack.appended_blocks].
|
|
23
|
+
appended_blocks: [...pack.appended_blocks].toSorted((left, right) =>
|
|
24
24
|
`${left.to}:${left.marker}`.localeCompare(`${right.to}:${right.marker}`),
|
|
25
25
|
),
|
|
26
26
|
env: uniqueSorted(pack.env),
|
|
@@ -35,7 +35,7 @@ export function normalizeState(state: StateFile): StateFile {
|
|
|
35
35
|
schema_version: 1,
|
|
36
36
|
installed_packs: [...parsed.installed_packs]
|
|
37
37
|
.map(normalizeInstalledPack)
|
|
38
|
-
.
|
|
38
|
+
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
package/src/resolver.ts
CHANGED
|
@@ -111,7 +111,7 @@ function findProviders(
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
return providers.
|
|
114
|
+
return providers.toSorted();
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
function firstScaffoldError(
|
|
@@ -232,7 +232,7 @@ function sortInstallNames(
|
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
state.set(name, 'visiting');
|
|
235
|
-
const deps = [...(dependencies.get(name) ?? [])].
|
|
235
|
+
const deps = [...(dependencies.get(name) ?? [])].toSorted();
|
|
236
236
|
for (const dependency of deps) {
|
|
237
237
|
const cycle = visit(dependency, [...path, dependency]);
|
|
238
238
|
if (cycle) {
|