ai-spec-dev 0.35.0 → 0.37.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.
Files changed (38) hide show
  1. package/RELEASE_LOG.md +139 -0
  2. package/cli/commands/config.ts +18 -0
  3. package/cli/commands/create.ts +16 -1
  4. package/cli/utils.ts +4 -0
  5. package/core/code-generator.ts +6 -4
  6. package/core/dsl-extractor.ts +9 -1
  7. package/core/dsl-feedback.ts +7 -1
  8. package/core/dsl-validator.ts +32 -0
  9. package/core/key-store.ts +5 -4
  10. package/core/provider-utils.ts +39 -4
  11. package/dist/cli/index.js +121 -14
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/index.mjs +122 -15
  14. package/dist/cli/index.mjs.map +1 -1
  15. package/dist/index.d.mts +16 -1
  16. package/dist/index.d.ts +16 -1
  17. package/dist/index.js +77 -8
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +77 -9
  20. package/dist/index.mjs.map +1 -1
  21. package/package.json +1 -1
  22. package/tests/code-generator.test.ts +253 -0
  23. package/tests/context-loader.test.ts +207 -0
  24. package/tests/dsl-validator.test.ts +105 -0
  25. package/tests/mock-server-generator.test.ts +404 -0
  26. package/tests/openapi-exporter.test.ts +310 -0
  27. package/tests/reviewer.test.ts +214 -0
  28. package/tests/spec-generator.test.ts +228 -0
  29. package/tests/spec-versioning.test.ts +205 -0
  30. package/tests/types-generator.test.ts +347 -0
  31. package/tests/vcr.test.ts +355 -0
  32. package/.claude/commands/add-lesson.md +0 -34
  33. package/.claude/commands/check-layers.md +0 -65
  34. package/.claude/commands/installed-deps.md +0 -35
  35. package/.claude/commands/recall-lessons.md +0 -40
  36. package/.claude/commands/scan-singletons.md +0 -45
  37. package/.claude/commands/verify-imports.md +0 -48
  38. package/.claude/settings.local.json +0 -24
@@ -132,14 +132,49 @@ function classifyError(err, label) {
132
132
  const e = err;
133
133
  const status = e.status ?? e.response?.status;
134
134
  if (status === 401 || status === 403)
135
- return new ProviderError(`Auth error \u2014 check your API key (${label})`, "auth", err);
135
+ return new ProviderError(
136
+ `Auth error (${label}): API key is invalid or expired.
137
+ \u2192 Check that the correct API key is set in your environment or ~/.ai-spec-keys.json
138
+ \u2192 Run "ai-spec model" to reconfigure your provider and key`,
139
+ "auth",
140
+ err
141
+ );
136
142
  if (status === 429)
137
- return new ProviderError(`Rate limit hit (${label}) \u2014 try again later or switch provider`, "rate_limit", err);
143
+ return new ProviderError(
144
+ `Rate limit hit (${label}): too many requests.
145
+ \u2192 Wait a few minutes and retry, or switch to a different provider/model
146
+ \u2192 Check your provider's billing dashboard for quota status`,
147
+ "rate_limit",
148
+ err
149
+ );
138
150
  if (e._timeout || e.message?.toLowerCase().includes("timed out"))
139
151
  return new ProviderError(`Request timed out (${label})`, "timeout", err);
140
152
  if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED")
141
- return new ProviderError(`Network error \u2014 check connection/proxy (${label}): ${e.message}`, "network", err);
142
- return new ProviderError(`Provider error (${label}): ${e.message}`, "provider", err);
153
+ return new ProviderError(
154
+ `Network error (${label}): ${e.message}
155
+ \u2192 Check your internet connection and proxy settings (HTTPS_PROXY)
156
+ \u2192 If behind a firewall, ensure the provider's API endpoint is reachable`,
157
+ "network",
158
+ err
159
+ );
160
+ const msg = e.message ?? "";
161
+ if (status === 404 || msg.includes("model") && (msg.includes("not found") || msg.includes("does not exist")))
162
+ return new ProviderError(
163
+ `Model not found (${label}): ${msg}
164
+ \u2192 Run "ai-spec model" to see available models for your provider
165
+ \u2192 The model name may have changed \u2014 check your provider's documentation`,
166
+ "provider",
167
+ err
168
+ );
169
+ if (msg.includes("insufficient") || msg.includes("quota") || msg.includes("balance"))
170
+ return new ProviderError(
171
+ `Quota/balance error (${label}): ${msg}
172
+ \u2192 Check your provider's billing dashboard
173
+ \u2192 Consider switching to a different provider with "ai-spec model"`,
174
+ "provider",
175
+ err
176
+ );
177
+ return new ProviderError(`Provider error (${label}): ${msg}`, "provider", err);
143
178
  }
144
179
  function isRetryable(err) {
145
180
  const e = err;
@@ -4165,7 +4200,7 @@ ${currentSpec}`,
4165
4200
 
4166
4201
  // core/code-generator.ts
4167
4202
  import chalk8 from "chalk";
4168
- import { execSync } from "child_process";
4203
+ import { execSync, spawnSync } from "child_process";
4169
4204
  import * as path9 from "path";
4170
4205
  import * as fs10 from "fs-extra";
4171
4206
 
@@ -4792,6 +4827,21 @@ function validateDsl(raw) {
4792
4827
  for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
4793
4828
  validateEndpoint(eps[i], `endpoints[${i}]`, errors);
4794
4829
  }
4830
+ const seenEpIds = /* @__PURE__ */ new Set();
4831
+ for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
4832
+ const ep = eps[i];
4833
+ if (ep && typeof ep === "object" && typeof ep["id"] === "string") {
4834
+ const id = ep["id"];
4835
+ if (seenEpIds.has(id)) {
4836
+ errors.push({
4837
+ path: `endpoints[${i}].id`,
4838
+ message: `Duplicate endpoint id "${id}" \u2014 each endpoint must have a unique id`
4839
+ });
4840
+ } else {
4841
+ seenEpIds.add(id);
4842
+ }
4843
+ }
4844
+ }
4795
4845
  }
4796
4846
  if (obj["behaviors"] !== void 0) {
4797
4847
  if (!Array.isArray(obj["behaviors"])) {
@@ -4848,6 +4898,21 @@ function validateModel(raw, path40, errors) {
4848
4898
  for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4849
4899
  validateModelField(fields[j2], `${path40}.fields[${j2}]`, errors);
4850
4900
  }
4901
+ const seenFieldNames = /* @__PURE__ */ new Set();
4902
+ for (let j2 = 0; j2 < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j2++) {
4903
+ const f = fields[j2];
4904
+ if (f && typeof f === "object" && typeof f["name"] === "string") {
4905
+ const name = f["name"];
4906
+ if (seenFieldNames.has(name)) {
4907
+ errors.push({
4908
+ path: `${path40}.fields[${j2}].name`,
4909
+ message: `Duplicate field name "${name}" \u2014 each field within a model must have a unique name`
4910
+ });
4911
+ } else {
4912
+ seenFieldNames.add(name);
4913
+ }
4914
+ }
4915
+ }
4851
4916
  }
4852
4917
  if (m["relations"] !== void 0) {
4853
4918
  if (!Array.isArray(m["relations"])) {
@@ -5315,7 +5380,10 @@ var DslExtractor = class {
5315
5380
  * - throws if user chose to abort
5316
5381
  */
5317
5382
  async extract(specContent, opts = {}) {
5318
- const specForAI = specContent.length > MAX_SPEC_CHARS ? specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)" : specContent;
5383
+ const specForAI = specContent.length > MAX_SPEC_CHARS ? (() => {
5384
+ console.log(chalk6.yellow(` \u26A0 Spec is ${specContent.length} chars \u2014 truncating to ${MAX_SPEC_CHARS} for DSL extraction. Details at the end may be lost.`));
5385
+ return specContent.slice(0, MAX_SPEC_CHARS) + "\n... (truncated for DSL extraction)";
5386
+ })() : specContent;
5319
5387
  let lastRawOutput = "";
5320
5388
  let lastErrors = [];
5321
5389
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -5339,6 +5407,11 @@ var DslExtractor = class {
5339
5407
  parsed = parseJsonFromOutput(rawOutput);
5340
5408
  } catch (parseErr) {
5341
5409
  console.log(chalk6.red(` \u2718 Failed to parse JSON from AI output: ${parseErr.message}`));
5410
+ const preview = rawOutput.slice(0, 500).replace(/\n/g, "\\n");
5411
+ console.log(chalk6.gray(` AI output preview (first 500 chars): ${preview}`));
5412
+ if (rawOutput.length > MAX_SPEC_CHARS) {
5413
+ console.log(chalk6.gray(` Note: spec was truncated to ${MAX_SPEC_CHARS} chars \u2014 long specs may lose context`));
5414
+ }
5342
5415
  lastErrors = [{ path: "root", message: "Output is not valid JSON \u2014 see raw output above" }];
5343
5416
  if (attempt < MAX_RETRIES) continue;
5344
5417
  return this.handleFailure(opts, "AI produced invalid JSON after retries");
@@ -6447,9 +6520,10 @@ ${tasks.map((t) => `${t.id} [${t.layer}] ${t.title}
6447
6520
  console.log(chalk8.cyan(` \u{1F916} Auto mode: running claude -p (non-interactive)...`));
6448
6521
  console.log(chalk8.gray(` Spec: ${specFilePath}`));
6449
6522
  try {
6450
- execSync(`${claudeCmd} -p "${promptContent.replace(/"/g, '\\"')}"`, {
6523
+ spawnSync(claudeCmd, ["-p", promptContent], {
6451
6524
  cwd: workingDir,
6452
- stdio: "inherit"
6525
+ stdio: "inherit",
6526
+ shell: false
6453
6527
  });
6454
6528
  console.log(chalk8.green("\n \u2714 Claude Code completed."));
6455
6529
  } catch {
@@ -6501,9 +6575,10 @@ Full spec is at: ${specFilePath}
6501
6575
  Implement ONLY this task. Do not implement other tasks.`;
6502
6576
  let taskStatus = "done";
6503
6577
  try {
6504
- execSync(`${claudeCmd} -p "${taskPrompt.replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`, {
6578
+ spawnSync(claudeCmd, ["-p", taskPrompt], {
6505
6579
  cwd: workingDir,
6506
- stdio: "inherit"
6580
+ stdio: "inherit",
6581
+ shell: false
6507
6582
  });
6508
6583
  completed++;
6509
6584
  } catch {
@@ -9285,7 +9360,7 @@ function assessDslRichness(dsl) {
9285
9360
  const endpointsWithoutErrors = dsl.endpoints.filter(
9286
9361
  (ep) => !ep.errors || ep.errors.length === 0
9287
9362
  );
9288
- if (endpointsWithoutErrors.length > 0 && dsl.endpoints.length >= 2) {
9363
+ if (endpointsWithoutErrors.length === dsl.endpoints.length && dsl.endpoints.length >= 2) {
9289
9364
  gaps.push({
9290
9365
  code: "missing_errors",
9291
9366
  message: `${endpointsWithoutErrors.length}/${dsl.endpoints.length} endpoints have no error definitions`,
@@ -9871,13 +9946,15 @@ async function readStore() {
9871
9946
  if (await fs19.pathExists(KEY_STORE_FILE)) {
9872
9947
  return await fs19.readJson(KEY_STORE_FILE);
9873
9948
  }
9874
- } catch {
9949
+ } catch (err) {
9950
+ console.warn(`Warning: Could not read key store at ${KEY_STORE_FILE}: ${err.message}. Using empty store.`);
9875
9951
  }
9876
9952
  return {};
9877
9953
  }
9878
9954
  async function writeStore(store) {
9879
- await fs19.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
9955
+ await fs19.ensureFile(KEY_STORE_FILE);
9880
9956
  await fs19.chmod(KEY_STORE_FILE, 384);
9957
+ await fs19.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
9881
9958
  }
9882
9959
  async function getSavedKey(provider) {
9883
9960
  const store = await readStore();
@@ -10970,8 +11047,10 @@ function registerCreate(program2) {
10970
11047
  console.log(chalk20.cyan("[8/9] TDD mode \u2014 error feedback loop driving implementation to pass tests..."));
10971
11048
  }
10972
11049
  runLogger.stageStart("error_feedback");
11050
+ const defaultCycles = opts.tdd ? 3 : 2;
11051
+ const maxCycles = config2.maxErrorCycles ?? defaultCycles;
10973
11052
  compilePassed = await runErrorFeedback(codegenProvider, workingDir, extractedDsl, {
10974
- maxCycles: opts.tdd ? 3 : 2
11053
+ maxCycles
10975
11054
  });
10976
11055
  runLogger.stageEnd("error_feedback");
10977
11056
  }
@@ -11074,6 +11153,18 @@ function registerCreate(program2) {
11074
11153
  logger: runLogger
11075
11154
  });
11076
11155
  printSelfEval(selfEvalResult);
11156
+ const minHarness = config2.minHarnessScore ?? 0;
11157
+ if (minHarness > 0 && selfEvalResult.harnessScore < minHarness && !opts.force) {
11158
+ console.log(chalk20.red(
11159
+ `
11160
+ \u2718 Harness score ${selfEvalResult.harnessScore}/10 is below the minimum threshold ${minHarness}/10.`
11161
+ ));
11162
+ console.log(chalk20.gray(` Gate threshold set in .ai-spec.json \u2192 "minHarnessScore": ${minHarness}`));
11163
+ console.log(chalk20.gray(` Use --force to bypass, or improve the spec and re-run.`));
11164
+ runLogger.stageEnd("self_eval", { gateBlocked: true, score: selfEvalResult.harnessScore, threshold: minHarness });
11165
+ runLogger.finish();
11166
+ process.exit(1);
11167
+ }
11077
11168
  if (accumulatePromise) await accumulatePromise;
11078
11169
  if (specVcrRecorder) {
11079
11170
  const vcrPath = await specVcrRecorder.save(currentDir, runId, codegenVcrRecorder ?? void 0);
@@ -11645,7 +11736,7 @@ import * as path26 from "path";
11645
11736
  import * as fs27 from "fs-extra";
11646
11737
  import chalk24 from "chalk";
11647
11738
  function registerConfig(program2) {
11648
- program2.command("config").description(`Set default configuration for this project (saved to ${CONFIG_FILE})`).option("--provider <name>", "Default AI provider for spec generation").option("--model <name>", "Default model for spec generation").option("--codegen <mode>", "Default code generation mode (claude-code|api|plan)").option("--codegen-provider <name>", "Default provider for code generation").option("--codegen-model <name>", "Default model for code generation").option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)").option("--show", "Print current configuration").option("--reset", "Reset configuration to empty").option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json").option("--clear-key <provider>", "Delete saved API key for a specific provider").option("--list-keys", "Show which providers have a saved key").action(async (opts) => {
11739
+ program2.command("config").description(`Set default configuration for this project (saved to ${CONFIG_FILE})`).option("--provider <name>", "Default AI provider for spec generation").option("--model <name>", "Default model for spec generation").option("--codegen <mode>", "Default code generation mode (claude-code|api|plan)").option("--codegen-provider <name>", "Default provider for code generation").option("--codegen-model <name>", "Default model for code generation").option("--min-spec-score <score>", "Minimum overall spec score (1-10) to pass Approval Gate (0 = disabled)").option("--min-harness-score <score>", "Minimum harness score (1-10) for pipeline success (0 = disabled)").option("--max-error-cycles <n>", "Maximum error-feedback fix cycles (1-10, default: 2)").option("--show", "Print current configuration").option("--reset", "Reset configuration to empty").option("--clear-keys", "Delete all saved API keys from ~/.ai-spec-keys.json").option("--clear-key <provider>", "Delete saved API key for a specific provider").option("--list-keys", "Show which providers have a saved key").action(async (opts) => {
11649
11740
  const currentDir = process.cwd();
11650
11741
  const configPath = path26.join(currentDir, CONFIG_FILE);
11651
11742
  if (opts.clearKeys) {
@@ -11703,6 +11794,22 @@ File: ${KEY_STORE_FILE}`));
11703
11794
  }
11704
11795
  updated.minSpecScore = score;
11705
11796
  }
11797
+ if (opts.minHarnessScore !== void 0) {
11798
+ const score = parseInt(opts.minHarnessScore, 10);
11799
+ if (isNaN(score) || score < 0 || score > 10) {
11800
+ console.error(chalk24.red(" --min-harness-score must be a number between 0 and 10"));
11801
+ process.exit(1);
11802
+ }
11803
+ updated.minHarnessScore = score;
11804
+ }
11805
+ if (opts.maxErrorCycles !== void 0) {
11806
+ const cycles = parseInt(opts.maxErrorCycles, 10);
11807
+ if (isNaN(cycles) || cycles < 1 || cycles > 10) {
11808
+ console.error(chalk24.red(" --max-error-cycles must be a number between 1 and 10"));
11809
+ process.exit(1);
11810
+ }
11811
+ updated.maxErrorCycles = cycles;
11812
+ }
11706
11813
  await fs27.writeJson(configPath, updated, { spaces: 2 });
11707
11814
  console.log(chalk24.green(`\u2714 Config saved to ${configPath}`));
11708
11815
  console.log(JSON.stringify(updated, null, 2));