@planu/cli 5.0.0 → 5.1.1

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 (73) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/cli/commands/spec.js +10 -1
  3. package/dist/core/spec-validator.js +32 -18
  4. package/dist/engine/evidence-gates/artifact-reader.d.ts +2 -0
  5. package/dist/engine/evidence-gates/artifact-reader.js +59 -2
  6. package/dist/engine/evidence-gates/evidence-autofill.d.ts +10 -0
  7. package/dist/engine/evidence-gates/evidence-autofill.js +148 -0
  8. package/dist/engine/evidence-gates/evidence-skeletons.d.ts +19 -0
  9. package/dist/engine/evidence-gates/evidence-skeletons.js +69 -0
  10. package/dist/engine/execution/operation-journal.js +10 -4
  11. package/dist/engine/minimality/policy-loader.js +247 -6
  12. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +14 -14
  14. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.darwin-x64.node.sbom.json +14 -14
  16. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +14 -14
  18. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +14 -14
  20. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +14 -14
  22. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  23. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +14 -14
  24. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  25. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +14 -14
  26. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  27. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +14 -14
  28. package/dist/engine/reverse-engineer/api-detector.js +2 -13
  29. package/dist/engine/reverse-engineer/complexity-analyzer.js +2 -13
  30. package/dist/engine/reverse-engineer/config-analyzer.js +2 -13
  31. package/dist/engine/reverse-engineer/dependency-graph.js +2 -13
  32. package/dist/engine/reverse-engineer/test-analyzer.js +2 -13
  33. package/dist/engine/reverse-engineer/walk-ignore.d.ts +3 -0
  34. package/dist/engine/reverse-engineer/walk-ignore.js +26 -0
  35. package/dist/engine/spec-format/acceptance-criteria.js +13 -12
  36. package/dist/engine/spec-format/text-fences.js +20 -2
  37. package/dist/engine/spec-state-syncer.js +1 -1
  38. package/dist/engine/timing/budget.js +5 -1
  39. package/dist/server/routes/specs.js +7 -5
  40. package/dist/tools/challenge-spec-helpers.d.ts +10 -1
  41. package/dist/tools/challenge-spec-helpers.js +63 -22
  42. package/dist/tools/challenge-spec.js +18 -3
  43. package/dist/tools/check-readiness.js +37 -13
  44. package/dist/tools/create-spec/spec-builder.d.ts +7 -0
  45. package/dist/tools/create-spec/spec-builder.js +19 -4
  46. package/dist/tools/create-spec.js +156 -86
  47. package/dist/tools/register-spec-tools/core-spec-tools.js +13 -12
  48. package/dist/tools/sync-spec-state-handler.js +49 -1
  49. package/dist/tools/update-status/batch.d.ts +6 -2
  50. package/dist/tools/update-status/batch.js +58 -1
  51. package/dist/tools/update-status/dod-gates.d.ts +16 -1
  52. package/dist/tools/update-status/dod-gates.js +191 -1
  53. package/dist/tools/update-status/done-receipt-verifier.d.ts +8 -0
  54. package/dist/tools/update-status/done-receipt-verifier.js +37 -2
  55. package/dist/tools/update-status/evidence-gate.d.ts +4 -0
  56. package/dist/tools/update-status/evidence-gate.js +67 -2
  57. package/dist/tools/update-status/file-sync.d.ts +2 -2
  58. package/dist/tools/update-status/index.d.ts +23 -1
  59. package/dist/tools/update-status/index.js +201 -24
  60. package/dist/tools/update-status/transition-guard.js +13 -1
  61. package/dist/tools/workspace-dashboard-handler.js +38 -0
  62. package/dist/types/evidence-autofill.d.ts +34 -0
  63. package/dist/types/evidence-autofill.js +2 -0
  64. package/dist/types/index.d.ts +1 -0
  65. package/dist/types/index.js +1 -0
  66. package/dist/types/spec/core.d.ts +6 -0
  67. package/dist/types/spec/inputs.d.ts +7 -2
  68. package/dist/types/spec-format.d.ts +1 -1
  69. package/dist/types/transition-log.d.ts +1 -1
  70. package/dist/types/validation.d.ts +8 -2
  71. package/package.json +11 -10
  72. package/planu-native.json +1 -1
  73. package/planu-plugin.json +1 -1
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.0.0",
7
+ "bom-ref": "pkg:cargo/planu-core@5.1.1",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.0.0",
10
+ "version": "5.1.1",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "fc9c98775c98b2f91d36ce9cb74567a547512ba5d11635052f230868af9ca9e3"
14
+ "content": "301863177ab4858bbbcd6f1d5ac86af8f98c424512a54c28b5343c01c1bd377b"
15
15
  }
16
16
  ]
17
17
  }
@@ -291,9 +291,9 @@
291
291
  },
292
292
  {
293
293
  "type": "library",
294
- "bom-ref": "pkg:cargo/globset@0.4.19",
294
+ "bom-ref": "pkg:cargo/globset@0.4.20",
295
295
  "name": "globset",
296
- "version": "0.4.19",
296
+ "version": "0.4.20",
297
297
  "externalReferences": [
298
298
  {
299
299
  "type": "distribution",
@@ -529,9 +529,9 @@
529
529
  },
530
530
  {
531
531
  "type": "library",
532
- "bom-ref": "pkg:cargo/regex-automata@0.4.16",
532
+ "bom-ref": "pkg:cargo/regex-automata@0.4.18",
533
533
  "name": "regex-automata",
534
- "version": "0.4.16",
534
+ "version": "0.4.18",
535
535
  "externalReferences": [
536
536
  {
537
537
  "type": "distribution",
@@ -1046,12 +1046,12 @@
1046
1046
  ]
1047
1047
  },
1048
1048
  {
1049
- "ref": "pkg:cargo/globset@0.4.19",
1049
+ "ref": "pkg:cargo/globset@0.4.20",
1050
1050
  "dependsOn": [
1051
1051
  "pkg:cargo/aho-corasick@1.1.5",
1052
1052
  "pkg:cargo/bstr@1.13.0",
1053
1053
  "pkg:cargo/log@0.4.33",
1054
- "pkg:cargo/regex-automata@0.4.16",
1054
+ "pkg:cargo/regex-automata@0.4.18",
1055
1055
  "pkg:cargo/regex-syntax@0.8.11"
1056
1056
  ]
1057
1057
  },
@@ -1069,10 +1069,10 @@
1069
1069
  "ref": "pkg:cargo/ignore@0.4.25",
1070
1070
  "dependsOn": [
1071
1071
  "pkg:cargo/crossbeam-deque@0.8.7",
1072
- "pkg:cargo/globset@0.4.19",
1072
+ "pkg:cargo/globset@0.4.20",
1073
1073
  "pkg:cargo/log@0.4.33",
1074
1074
  "pkg:cargo/memchr@2.8.3",
1075
- "pkg:cargo/regex-automata@0.4.16",
1075
+ "pkg:cargo/regex-automata@0.4.18",
1076
1076
  "pkg:cargo/same-file@1.0.6",
1077
1077
  "pkg:cargo/walkdir@2.5.0",
1078
1078
  "pkg:cargo/winapi-util@0.1.11"
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.0.0",
1109
+ "ref": "pkg:cargo/planu-core@5.1.1",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1151,7 +1151,7 @@
1151
1151
  ]
1152
1152
  },
1153
1153
  {
1154
- "ref": "pkg:cargo/regex-automata@0.4.16",
1154
+ "ref": "pkg:cargo/regex-automata@0.4.18",
1155
1155
  "dependsOn": [
1156
1156
  "pkg:cargo/aho-corasick@1.1.5",
1157
1157
  "pkg:cargo/memchr@2.8.3",
@@ -1167,7 +1167,7 @@
1167
1167
  "dependsOn": [
1168
1168
  "pkg:cargo/aho-corasick@1.1.5",
1169
1169
  "pkg:cargo/memchr@2.8.3",
1170
- "pkg:cargo/regex-automata@0.4.16",
1170
+ "pkg:cargo/regex-automata@0.4.18",
1171
1171
  "pkg:cargo/regex-syntax@0.8.11"
1172
1172
  ]
1173
1173
  },
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { shouldIgnoreDir } from './walk-ignore.js';
3
4
  const ROUTE_PATTERNS = [
4
5
  // Express/Koa/Hono: app.get('/path', handler) or router.post(...)
5
6
  {
@@ -69,18 +70,6 @@ const SOURCE_EXTENSIONS = new Set([
69
70
  '.kt',
70
71
  '.rb',
71
72
  ]);
72
- const IGNORE_DIRS = new Set([
73
- 'node_modules',
74
- '.git',
75
- 'dist',
76
- 'build',
77
- 'out',
78
- '.next',
79
- '.nuxt',
80
- 'coverage',
81
- 'vendor',
82
- '__pycache__',
83
- ]);
84
73
  function collectSourceFiles(dir) {
85
74
  const results = [];
86
75
  const queue = [dir];
@@ -97,7 +86,7 @@ function collectSourceFiles(dir) {
97
86
  continue;
98
87
  }
99
88
  for (const entry of entries) {
100
- if (IGNORE_DIRS.has(entry.name)) {
89
+ if (shouldIgnoreDir(entry.name)) {
101
90
  continue;
102
91
  }
103
92
  const full = path.join(current, entry.name);
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { shouldIgnoreDir } from './walk-ignore.js';
3
4
  const SOURCE_EXTENSIONS = new Set([
4
5
  '.ts',
5
6
  '.tsx',
@@ -14,18 +15,6 @@ const SOURCE_EXTENSIONS = new Set([
14
15
  '.rs',
15
16
  '.rb',
16
17
  ]);
17
- const IGNORE_DIRS = new Set([
18
- 'node_modules',
19
- '.git',
20
- 'dist',
21
- 'build',
22
- 'out',
23
- '.next',
24
- '.nuxt',
25
- 'coverage',
26
- 'vendor',
27
- '__pycache__',
28
- ]);
29
18
  // Patterns that increase cyclomatic complexity
30
19
  const BRANCH_PATTERNS = [
31
20
  /\bif\s*\(/g,
@@ -73,7 +62,7 @@ function collectSourceFiles(dir) {
73
62
  continue;
74
63
  }
75
64
  for (const entry of entries) {
76
- if (IGNORE_DIRS.has(entry.name)) {
65
+ if (shouldIgnoreDir(entry.name)) {
77
66
  continue;
78
67
  }
79
68
  const full = path.join(current, entry.name);
@@ -1,6 +1,7 @@
1
1
  import { technologyValue } from '../technology-registry.js';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { shouldIgnoreDir } from './walk-ignore.js';
4
5
  const ENV_VAR_PATTERNS = [
5
6
  // JS/TS: process.env.VAR_NAME
6
7
  /process\.env\.([A-Z_][A-Z0-9_]*)/g,
@@ -55,18 +56,6 @@ const INFRA_INDICATORS = {
55
56
  'azure-pipelines.yml': 'Azure Pipelines',
56
57
  'cloudbuild.yaml': 'Google Cloud Build',
57
58
  };
58
- const IGNORE_DIRS = new Set([
59
- 'node_modules',
60
- '.git',
61
- 'dist',
62
- 'build',
63
- 'out',
64
- '.next',
65
- '.nuxt',
66
- 'coverage',
67
- 'vendor',
68
- '__pycache__',
69
- ]);
70
59
  const SOURCE_EXTENSIONS = new Set([
71
60
  '.ts',
72
61
  '.tsx',
@@ -97,7 +86,7 @@ function collectAllFiles(dir) {
97
86
  continue;
98
87
  }
99
88
  for (const entry of entries) {
100
- if (IGNORE_DIRS.has(entry.name)) {
89
+ if (shouldIgnoreDir(entry.name)) {
101
90
  continue;
102
91
  }
103
92
  const full = path.join(current, entry.name);
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { shouldIgnoreDir } from './walk-ignore.js';
3
4
  const IMPORT_PATTERNS = [
4
5
  // ES import: import ... from './foo'
5
6
  /(?:import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"])/g,
@@ -22,18 +23,6 @@ const SOURCE_EXTENSIONS = new Set([
22
23
  '.rs',
23
24
  '.rb',
24
25
  ]);
25
- const IGNORE_DIRS = new Set([
26
- 'node_modules',
27
- '.git',
28
- 'dist',
29
- 'build',
30
- 'out',
31
- '.next',
32
- '.nuxt',
33
- 'coverage',
34
- 'vendor',
35
- '__pycache__',
36
- ]);
37
26
  const HUB_THRESHOLD = 10;
38
27
  function collectSourceFiles(dir) {
39
28
  const results = [];
@@ -51,7 +40,7 @@ function collectSourceFiles(dir) {
51
40
  continue;
52
41
  }
53
42
  for (const entry of entries) {
54
- if (IGNORE_DIRS.has(entry.name)) {
43
+ if (shouldIgnoreDir(entry.name)) {
55
44
  continue;
56
45
  }
57
46
  const full = path.join(current, entry.name);
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { shouldIgnoreDir } from './walk-ignore.js';
3
4
  const TEST_PATTERNS = [
4
5
  /\.test\.\w+$/,
5
6
  /\.spec\.\w+$/,
@@ -25,18 +26,6 @@ const SOURCE_EXTENSIONS = new Set([
25
26
  '.c',
26
27
  '.swift',
27
28
  ]);
28
- const IGNORE_DIRS = new Set([
29
- 'node_modules',
30
- '.git',
31
- 'dist',
32
- 'build',
33
- 'out',
34
- '.next',
35
- '.nuxt',
36
- 'coverage',
37
- 'vendor',
38
- '__pycache__',
39
- ]);
40
29
  export function isTestFile(filePath) {
41
30
  const basename = path.basename(filePath);
42
31
  if (TEST_PATTERNS.some((p) => p.test(basename))) {
@@ -61,7 +50,7 @@ function collectFiles(dir) {
61
50
  continue;
62
51
  }
63
52
  for (const entry of entries) {
64
- if (IGNORE_DIRS.has(entry.name)) {
53
+ if (shouldIgnoreDir(entry.name)) {
65
54
  continue;
66
55
  }
67
56
  const full = path.join(current, entry.name);
@@ -0,0 +1,3 @@
1
+ export declare const WALK_IGNORE_DIRS: ReadonlySet<string>;
2
+ export declare function shouldIgnoreDir(name: string): boolean;
3
+ //# sourceMappingURL=walk-ignore.d.ts.map
@@ -0,0 +1,26 @@
1
+ // engine/reverse-engineer/walk-ignore.ts — shared directory-walk ignore list
2
+ // Consumed by all reverse-engineer analyzers (dependency-graph, test-analyzer,
3
+ // api-detector, config-analyzer, complexity-analyzer) so the ignore rules stay
4
+ // in one place instead of five duplicated copies.
5
+ export const WALK_IGNORE_DIRS = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ 'dist',
9
+ 'build',
10
+ 'out',
11
+ '.next',
12
+ '.nuxt',
13
+ 'coverage',
14
+ 'vendor',
15
+ '__pycache__',
16
+ '.worktrees',
17
+ '.claude',
18
+ 'planu',
19
+ 'website',
20
+ 'data',
21
+ 'benchmarks',
22
+ ]);
23
+ export function shouldIgnoreDir(name) {
24
+ return WALK_IGNORE_DIRS.has(name);
25
+ }
26
+ //# sourceMappingURL=walk-ignore.js.map
@@ -1,6 +1,7 @@
1
1
  import { parseFrontmatterScenarios } from '../validator/spec-compliance-runner.js';
2
2
  import { stripFrontmatter } from '../frontmatter-parser.js';
3
3
  import { extractListItems, extractSection } from './markdown-sections.js';
4
+ import { stripFencedBlocks } from './text-fences.js';
4
5
  import { createCriterionIdentity, normalizeCriterionText } from '../criterion-identity.js';
5
6
  const BDD_STEP = /^(GIVEN|WHEN|THEN|AND|DADO|CUANDO|ENTONCES|Y)\b/i;
6
7
  const BDD_START = /^(GIVEN|DADO)\b/i;
@@ -91,7 +92,7 @@ function extractSectionCriteria(section) {
91
92
  return extractBddBlocks(section);
92
93
  }
93
94
  function extractBodyCriteria(raw, options) {
94
- const body = stripFrontmatter(raw);
95
+ const body = stripFencedBlocks(stripFrontmatter(raw));
95
96
  const acceptanceCriteria = extractSection(body, 'acceptance criteria', 'criterios de aceptaci');
96
97
  if (acceptanceCriteria) {
97
98
  const sectionCriteria = extractSectionCriteria(acceptanceCriteria);
@@ -99,18 +100,18 @@ function extractBodyCriteria(raw, options) {
99
100
  return sectionCriteria;
100
101
  }
101
102
  }
102
- if (options.allowLegacyBodyFallback === false) {
103
- return [];
104
- }
105
- const checkboxCriteria = body
106
- .split('\n')
107
- .map((line) => line.trim())
108
- .filter((line) => /^-\s*\[[ x]\]/i.test(line))
109
- .map((line) => line.replace(/^-\s*\[[ x]\]\s*/i, '').trim());
110
- if (checkboxCriteria.length > 0) {
111
- return checkboxCriteria;
103
+ if (options.allowLegacyBodyFallback !== false) {
104
+ const checkboxCriteria = body
105
+ .split('\n')
106
+ .map((line) => line.trim())
107
+ .filter((line) => /^-\s*\[[ x]\]/i.test(line))
108
+ .map((line) => line.replace(/^-\s*\[[ x]\]\s*/i, '').trim());
109
+ if (checkboxCriteria.length > 0) {
110
+ return checkboxCriteria;
111
+ }
112
+ return extractBddBlocks(body);
112
113
  }
113
- return extractBddBlocks(body);
114
+ return [];
114
115
  }
115
116
  function buildCriterion(text, source, index) {
116
117
  const trimmed = text.trim();
@@ -4,7 +4,25 @@
4
4
  * Returns the remaining prose only, preserving line structure.
5
5
  */
6
6
  export function stripFencedBlocks(md) {
7
- // Match ``` or ~~~ fences (with optional language specifier) spanning multiple lines
8
- return md.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm, '');
7
+ const lines = md.split('\n');
8
+ let openFence;
9
+ return lines
10
+ .map((line) => {
11
+ if (openFence !== undefined) {
12
+ const close = new RegExp(`^ {0,3}${openFence.marker}{${String(openFence.length)},}[ \\t]*\\r?$`);
13
+ if (close.test(line)) {
14
+ openFence = undefined;
15
+ }
16
+ return '';
17
+ }
18
+ const opening = /^ {0,3}(`{3,}|~{3,})[^\r\n]*\r?$/.exec(line);
19
+ if (opening?.[1]) {
20
+ const marker = opening[1][0];
21
+ openFence = { marker, length: opening[1].length };
22
+ return '';
23
+ }
24
+ return line;
25
+ })
26
+ .join('\n');
9
27
  }
10
28
  //# sourceMappingURL=text-fences.js.map
@@ -72,7 +72,7 @@ async function processSingleSpec(filePath, dataStatusMap, report) {
72
72
  * The caller (tool layer) is responsible for persisting updates to storage.
73
73
  */
74
74
  export async function detectSpecStateDivergences(projectPath, dataEntries) {
75
- const report = { synced: 0, divergences: [], errors: [], rejected: [] };
75
+ const report = { synced: 0, divergences: [], errors: [], rejected: [], ghosts: [] };
76
76
  const updates = [];
77
77
  const dataStatusMap = new Map(dataEntries.map((s) => [s.id, s.status]));
78
78
  let specFiles;
@@ -107,7 +107,11 @@ export async function withTotalBudget(operationName, ceilingMs, fn) {
107
107
  timedOut: false,
108
108
  category: 'operation_failed',
109
109
  elapsedMs,
110
- warning: `[SPEC-713] Operation "${operationName}" failed after ${String(elapsedMs)}ms (operation_failed)`,
110
+ // SPEC-1351: keep the thrown error's message — the production bug this
111
+ // fixed surfaced only as an opaque "operation_failed" with no way to
112
+ // tell what actually broke — but never leak the raw call stack into
113
+ // this user-facing warning string.
114
+ warning: `[SPEC-713] Operation "${operationName}" failed after ${String(elapsedMs)}ms (operation_failed): ${outcome.error.message}`,
111
115
  };
112
116
  }
113
117
  return {
@@ -26,9 +26,7 @@ const VALID_STATUSES = [
26
26
  'discarded',
27
27
  ];
28
28
  function containsReconciliationAuthorityFields(body) {
29
- return ('reconciliationRequestId' in body ||
30
- 'expectedImplementingTransitionId' in body ||
31
- 'implementationReviewDigest' in body);
29
+ return 'reconciliationRequestId' in body || 'expectedImplementingTransitionId' in body;
32
30
  }
33
31
  /** Resolve projectPath from the request Host header or fallback to process.cwd(). */
34
32
  function getProjectPath(ctx) {
@@ -192,10 +190,13 @@ export async function handleUpdateSpecStatus(ctx) {
192
190
  sendBadRequest(ctx.res, 'Request body must be a JSON object');
193
191
  return;
194
192
  }
195
- const { status } = body;
193
+ const { status, implementationReviewDigest } = body;
196
194
  if (containsReconciliationAuthorityFields(body)) {
197
195
  ctx.res.writeHead(403, { 'Content-Type': 'application/json' });
198
- ctx.res.end(JSON.stringify({ error: 'Reconciliation is available only through local MCP.' }));
196
+ ctx.res.end(JSON.stringify({
197
+ error: 'Reconciliation is available only through local MCP.',
198
+ code: 'RECONCILIATION_UNTRUSTED_SURFACE',
199
+ }));
199
200
  return;
200
201
  }
201
202
  if (typeof status !== 'string' || !VALID_STATUSES.includes(status)) {
@@ -219,6 +220,7 @@ export async function handleUpdateSpecStatus(ctx) {
219
220
  projectPath,
220
221
  trigger: 'http',
221
222
  actor: 'http-api',
223
+ implementationReviewDigest: implementationReviewDigest,
222
224
  });
223
225
  if (result.isError) {
224
226
  const reason = result.content[0]?.type === 'text' ? result.content[0].text : 'Gate blocked transition';
@@ -1,5 +1,14 @@
1
1
  import type { FailureScenario, ConcurrencyAnalysis, RiskLevel, Spec, ProjectKnowledge } from '../types/index.js';
2
- export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysis;
2
+ /**
3
+ * Result of generateConcurrencyAnalysis. `suppressedCount` tracks candidates
4
+ * that matched a domain keyword (counter/status/file/session/cache) but were
5
+ * dropped because the spec content contains no corroborating concurrency
6
+ * context (e.g. "concurrent", "simultaneously", "multiple users") — SPEC-1350.
7
+ */
8
+ export interface ConcurrencyAnalysisResult extends ConcurrencyAnalysis {
9
+ suppressedCount: number;
10
+ }
11
+ export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysisResult;
3
12
  export declare function buildScalabilityAssessment(spec: Spec, knowledge: ProjectKnowledge, scenarios: FailureScenario[]): string;
4
13
  export declare function calculateOverallRisk(scenarios: FailureScenario[], concurrency: ConcurrencyAnalysis): RiskLevel;
5
14
  export declare function readSpecContent(spec: Spec): Promise<string>;
@@ -1,55 +1,96 @@
1
1
  // tools/challenge-spec-helpers.ts — Concurrency, risk, and spec-reader helpers for challenge-spec
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { readSpecTechnicalSection } from '../engine/spec-format/read-technical-section.js';
4
- // --- Concurrency analysis ---
4
+ import { hasAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../engine/text-signal-boundaries.js';
5
+ // Bare keyword matches (counter, status, draft, ...) are common in unrelated
6
+ // prose (variable names, file paths, section headers, bug narratives). They
7
+ // only ground a race-condition/shared-state finding when the spec content
8
+ // also affirms an explicit concurrency context — otherwise they are boilerplate.
9
+ const CONCURRENCY_CONTEXT_PATTERN = /\b(?:concurrent(?:ly)?|simultaneous(?:ly)?|in\s+parallel|parallel\s+writes?|race\s+condition|multiple\s+users?|at\s+the\s+same\s+time|two\s+users|shared\s+state|atomic(?:ally)?|thread-safe|synchroniz(?:e|es|ed|ing|ation)|multiple\s+(?:tabs|devices|requests|clients))\b/i;
10
+ const AUTH_PATTERN = /\b(?:login|auth)\b/i;
11
+ const LIST_SEARCH_PATTERN = /\b(?:list|search|feed)\b/i;
12
+ const WRITE_PATTERN = /\b(?:submit|create|update)\b/i;
13
+ const COUNTER_PATTERN = /\b(?:counter|increment|balance|stock|quantity|vote)\b/i;
14
+ const STATUS_PATTERN = /\bstatus\b/i;
15
+ const TRANSITION_PATTERN = /\b(?:update|transition)\b/i;
16
+ const FILE_PATTERN = /\b(?:file|upload|resource)\b/i;
17
+ const SESSION_PATTERN = /\b(?:session|cart|draft)\b/i;
18
+ const CACHE_PATTERN = /\b(?:cache|global|config)\b/i;
19
+ /** Grounded, boundary-aware, negation-aware match against contract prose only. */
20
+ function affirms(content, pattern) {
21
+ return hasAffirmedMatch(content, pattern);
22
+ }
5
23
  export function generateConcurrencyAnalysis(_spec, content, _knowledge) {
6
- const lower = content.toLowerCase();
24
+ // Drop frontmatter, code fences, and quoted/backticked examples before
25
+ // keyword inference — mirrors detectChallengeCapabilities so this generator
26
+ // reads the same "contract prose" the capability gate already reads.
27
+ const contract = stripMetaAnalysisText(stripNonContractText(content));
7
28
  const hotPaths = [];
8
29
  const raceConditions = [];
9
30
  const sharedState = [];
10
31
  const recommendations = [];
11
- // Detect hot paths
12
- if (lower.includes('login') || lower.includes('auth')) {
32
+ let suppressedCount = 0;
33
+ // Hot paths are informational only (they do not feed overallRisk), so a
34
+ // plain boundary-aware match is sufficient here — no context gate needed.
35
+ if (affirms(contract, AUTH_PATTERN)) {
13
36
  hotPaths.push('Authentication endpoint (high-frequency, shared session state)');
14
37
  }
15
- if (lower.includes('list') || lower.includes('search') || lower.includes('feed')) {
38
+ if (affirms(contract, LIST_SEARCH_PATTERN)) {
16
39
  hotPaths.push('List/search endpoints (high read frequency)');
17
40
  }
18
- if (lower.includes('submit') || lower.includes('create') || lower.includes('update')) {
41
+ if (affirms(contract, WRITE_PATTERN)) {
19
42
  hotPaths.push('Write endpoints (mutation-heavy, lock-prone)');
20
43
  }
21
- // Detect race conditions
22
- if (lower.includes('counter') ||
23
- lower.includes('increment') ||
24
- lower.includes('balance') ||
25
- lower.includes('stock') ||
26
- lower.includes('quantity') ||
27
- lower.includes('vote')) {
44
+ const hasConcurrencyContext = affirms(contract, CONCURRENCY_CONTEXT_PATTERN);
45
+ const counterMatched = affirms(contract, COUNTER_PATTERN);
46
+ if (counterMatched && hasConcurrencyContext) {
28
47
  raceConditions.push('Counter/balance updates may lose increments under concurrent writes');
29
48
  recommendations.push('Use database-level atomic increments (UPDATE SET count = count + 1) instead of read-modify-write');
30
49
  }
31
- if (lower.includes('status') && (lower.includes('update') || lower.includes('transition'))) {
50
+ else if (counterMatched) {
51
+ suppressedCount += 1;
52
+ }
53
+ const statusMatched = affirms(contract, STATUS_PATTERN) && affirms(contract, TRANSITION_PATTERN);
54
+ if (statusMatched && hasConcurrencyContext) {
32
55
  raceConditions.push('Status transitions may conflict when two users update simultaneously');
33
56
  recommendations.push('Use optimistic locking (version column) or pessimistic locking (SELECT FOR UPDATE)');
34
57
  }
35
- if (lower.includes('file') || lower.includes('upload') || lower.includes('resource')) {
58
+ else if (statusMatched) {
59
+ suppressedCount += 1;
60
+ }
61
+ const fileMatched = affirms(contract, FILE_PATTERN);
62
+ if (fileMatched && hasConcurrencyContext) {
36
63
  raceConditions.push('Concurrent file writes to same resource may corrupt data');
37
64
  recommendations.push('Use advisory locks or rename-and-swap pattern for file operations');
38
65
  }
39
- // Detect shared state
40
- if (lower.includes('session') || lower.includes('cart') || lower.includes('draft')) {
66
+ else if (fileMatched) {
67
+ suppressedCount += 1;
68
+ }
69
+ const sessionMatched = affirms(contract, SESSION_PATTERN);
70
+ if (sessionMatched && hasConcurrencyContext) {
41
71
  sharedState.push('User session state (may be accessed from multiple tabs/devices)');
42
72
  }
43
- if (lower.includes('cache') || lower.includes('global') || lower.includes('config')) {
73
+ else if (sessionMatched) {
74
+ suppressedCount += 1;
75
+ }
76
+ const cacheMatched = affirms(contract, CACHE_PATTERN);
77
+ if (cacheMatched && hasConcurrencyContext) {
44
78
  sharedState.push('Global cache or configuration (requires synchronized access)');
45
79
  }
46
- // General recommendations
80
+ else if (cacheMatched) {
81
+ suppressedCount += 1;
82
+ }
83
+ // General recommendations — only emitted once a race condition is actually
84
+ // grounded, otherwise a formatting-only spec would still surface generic
85
+ // "use database transactions" boilerplate (SPEC-1350 AC1).
47
86
  if (raceConditions.length === 0) {
48
87
  recommendations.push('No obvious race conditions detected, but test concurrent scenarios in integration tests');
49
88
  }
50
- recommendations.push('Use database transactions for multi-step operations');
51
- recommendations.push('Implement idempotency keys for all mutation endpoints');
52
- return { hotPaths, raceConditions, sharedState, recommendations };
89
+ else {
90
+ recommendations.push('Use database transactions for multi-step operations');
91
+ recommendations.push('Implement idempotency keys for all mutation endpoints');
92
+ }
93
+ return { hotPaths, raceConditions, sharedState, recommendations, suppressedCount };
53
94
  }
54
95
  // --- Scalability and risk ---
55
96
  export function buildScalabilityAssessment(spec, knowledge, scenarios) {
@@ -171,9 +171,24 @@ export async function handleChallengeSpec(args, server) {
171
171
  // SPEC-615 AC3: Check criteria against prior decisions for technology contradictions
172
172
  await runPriorDecisionContradictions(projectId, spec.title, spec.tags, specContent, failureScenarios);
173
173
  // 6. Generate concurrency analysis
174
- const concurrencyAnalysis = focusAreas.includes('concurrency') && capabilities.concurrency
174
+ const concurrencyResult = focusAreas.includes('concurrency') && capabilities.concurrency
175
175
  ? generateConcurrencyAnalysis(spec, specContent, knowledge)
176
- : { hotPaths: [], raceConditions: [], sharedState: [], recommendations: [] };
176
+ : {
177
+ hotPaths: [],
178
+ raceConditions: [],
179
+ sharedState: [],
180
+ recommendations: [],
181
+ suppressedCount: 0,
182
+ };
183
+ // SPEC-1350: concurrency candidates that matched a domain keyword but lacked
184
+ // a corroborating concurrency context are suppressed before risk/summary math.
185
+ const concurrencySuppressedCount = concurrencyResult.suppressedCount;
186
+ const concurrencyAnalysis = {
187
+ hotPaths: concurrencyResult.hotPaths,
188
+ raceConditions: concurrencyResult.raceConditions,
189
+ sharedState: concurrencyResult.sharedState,
190
+ recommendations: concurrencyResult.recommendations,
191
+ };
177
192
  // Unsupported domain templates are removed before prioritization and risk calculation.
178
193
  const actionableFailureScenarios = failureScenarios.filter((scenario) => isScenarioSupportedByCapabilities(scenario, capabilities));
179
194
  const suppressedScenarioCount = failureScenarios.length - actionableFailureScenarios.length;
@@ -219,7 +234,7 @@ export async function handleChallengeSpec(args, server) {
219
234
  summary: {
220
235
  totalScenarios: failureScenarios.length,
221
236
  actionableScenarios: failureScenariosScored.length,
222
- suppressedLowRelevanceScenarios: suppressedScenarioCount,
237
+ suppressedLowRelevanceScenarios: suppressedScenarioCount + concurrencySuppressedCount,
223
238
  shownByDefault: 3,
224
239
  mustAddressBeforeCoding: prioritizedSummary,
225
240
  criticalImpact: actionableFailureScenarios.filter((s) => s.impact === 'critical').length,