@opengsd/gsd-core 1.9.0 → 1.9.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.
@@ -63,6 +63,33 @@ function findProjectRoot(startDir) {
63
63
  }
64
64
  return false;
65
65
  }
66
+ // #2843: nearest ancestor (including `from` itself) that contains a `.git`,
67
+ // bounded by `upTo` (exclusive). Returns the git-repo root, or null if none
68
+ // exists before `upTo` / the filesystem root. Used to detect a NESTED child
69
+ // repo whose root is strictly below a candidate ancestor `.planning/` — in
70
+ // that case the caller's repo boundary sits between start and the ancestor,
71
+ // so trusting the ancestor's `.planning/` would silently cross into a
72
+ // different project. (No `git` subprocess — fs walk only, matching
73
+ // isInsideGitRepo's deliberate no-spawn contract.)
74
+ function nearestGitRoot(from, upTo) {
75
+ let d = from;
76
+ while (d !== fsRoot) {
77
+ if (d === upTo)
78
+ break;
79
+ try {
80
+ if (node_fs_1.default.existsSync(d + node_path_1.default.sep + '.git'))
81
+ return d;
82
+ }
83
+ catch {
84
+ // ignore
85
+ }
86
+ const next = node_path_1.default.dirname(d);
87
+ if (next === d)
88
+ break;
89
+ d = next;
90
+ }
91
+ return null;
92
+ }
66
93
  let dir = resolvedStart;
67
94
  let depth = 0;
68
95
  while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
@@ -114,6 +141,18 @@ function findProjectRoot(startDir) {
114
141
  // claims our startDir — explicit sub_repos config takes precedence over the
115
142
  // implicit .git signal. (#1422)
116
143
  if (isInsideGitRepo(parent)) {
144
+ // #2843: do NOT cross a git-repo boundary. If the caller is inside its
145
+ // OWN nested repo whose root is strictly below `parent`, trusting
146
+ // `parent`'s .planning/ would silently resolve to a DIFFERENT project.
147
+ // isInsideGitRepo only proved SOME .git exists between start and parent;
148
+ // verify that .git is parent's own (or absent between), not a nested
149
+ // child repo. If nearestGitRoot finds a .git strictly below parent,
150
+ // the boundary is crossed — fall through (do not return parent).
151
+ if (nearestGitRoot(resolvedStart, parent) !== null) {
152
+ dir = parent;
153
+ depth += 1;
154
+ continue;
155
+ }
117
156
  // Lookahead: walk ancestors above `parent` to find a sub_repos claim.
118
157
  let ancestor = node_path_1.default.dirname(parent);
119
158
  let ancestorDepth = 0;
@@ -169,6 +208,15 @@ function findProjectRoot(startDir) {
169
208
  try {
170
209
  const candidatePlanning = parent2 + node_path_1.default.sep + '.planning';
171
210
  if (node_fs_1.default.existsSync(candidatePlanning) && node_fs_1.default.statSync(candidatePlanning).isDirectory()) {
211
+ // #2843: do not cross a git-repo boundary. If the caller is inside its
212
+ // own nested repo (a .git strictly below parent2), parent2's .planning/
213
+ // belongs to a DIFFERENT project — keep walking is wrong; stop and fall
214
+ // through to the startDir fallback instead of silently resolving to the
215
+ // ancestor project. (Reached only when no .git exists anywhere in the
216
+ // chain per the triage, but guard defensively.)
217
+ if (nearestGitRoot(resolvedStart, parent2) !== null) {
218
+ break;
219
+ }
172
220
  return parent2;
173
221
  }
174
222
  }
@@ -161,6 +161,15 @@ function verifySummaryCore(cwd, summaryPath, checkFileCount, opts) {
161
161
  // is still not read. Recovering it needs a real frontmatter parse, which is
162
162
  // deliberately left as a follow-up rather than smuggled in here.
163
163
  const mentionedFiles = new Set();
164
+ // #2844: Pattern 1 matches any backticked path-like token. A SUMMARY body is
165
+ // predominantly about what the phase DID, so a backticked path in prose ("Built
166
+ // `src/kept.ts`", a `- \`src/x.ts\`` list item) is a legitimate claim (#2685
167
+ // pins this). The false-positive class #2844 fixes is a path mentioned as a
168
+ // FUTURE/CONDITIONAL deliverable — "next phase will add `shared/types.ts`",
169
+ // "planned", "would", "to be created" — which is NOT a claim about this phase.
170
+ // Exclude those lines rather than requiring an explicit claim verb (which would
171
+ // drop the legitimate "Built …" / list-item forms #2685 protects).
172
+ const isFutureMention = (line) => /\b(?:will(?:\s+(?:add|create|build|land))?(?:[^.])?|(?:next|later|future)\s+phase|planned?|would\s+(?:be|add|create|build)|to\s+be\s+(?:added|created|built)|eventually|not\s+yet)\b/i.test(line);
164
173
  const patterns = [
165
174
  /`([^`]+\.[a-zA-Z]+)`/g,
166
175
  /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`[\]]+\.[a-zA-Z]+)`?/gi,
@@ -169,9 +178,16 @@ function verifySummaryCore(cwd, summaryPath, checkFileCount, opts) {
169
178
  let m;
170
179
  while ((m = pattern.exec(content)) !== null) {
171
180
  const filePath = m[1];
172
- if (filePath && isProbableProjectFile(filePath)) {
173
- mentionedFiles.add(filePath);
174
- }
181
+ if (!filePath || !isProbableProjectFile(filePath))
182
+ continue;
183
+ // #2844: skip a backticked path on a future/conditional line — it names a
184
+ // deliverable this phase did NOT produce, so probing it is a false positive.
185
+ const lineStart = content.lastIndexOf('\n', m.index) + 1;
186
+ const lineEnd = content.indexOf('\n', m.index);
187
+ const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
188
+ if (isFutureMention(line))
189
+ continue;
190
+ mentionedFiles.add(filePath);
175
191
  }
176
192
  }
177
193
  const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
@@ -464,7 +464,22 @@ FALLOW_OK=$(FALLOW_TMP=\"${FALLOW_JSON_PATH}.tmp\" node -e \"
464
464
  if [ \"$FALLOW_OK\" != \"1\" ]; then
465
465
  FALLOW_STDERR_SUMMARY=$(head -5 \"$FALLOW_STDERR_TMP\")
466
466
  rm -f \"${FALLOW_JSON_PATH}.tmp\" \"$FALLOW_STDERR_TMP\"
467
- echo \"WARNING: fallow structural pre-pass failed (exit ${FALLOW_EXIT}): ${FALLOW_STDERR_SUMMARY}\"
467
+ # #2667: distinguish a hard EXECUTION failure (the binary was found at step 1
468
+ # but would not run) from the binary-missing path (step 2). Exit 124 = timeout,
469
+ # 2 = usage error, 125 = spawn failure (e.g. Windows EINVAL on a .cmd shim —
470
+ # CVE-2024-27980, now mediated by run-with-timeout), 126/127 = not executable /
471
+ # not found. A non-zero exit here with a resolved binary means fallow is
472
+ # installed but did not produce a report — surface that loudly so a Windows
473
+ # user does not mistake it for "fallow absent".
474
+ case \"$FALLOW_EXIT\" in
475
+ 124) FALLOW_FAIL_KIND=\"timed out\" ;;
476
+ 2) FALLOW_FAIL_KIND=\"usage error\" ;;
477
+ 125) FALLOW_FAIL_KIND=\"spawn failure (the binary was found but did not start — e.g. a Windows .cmd shim; run-with-timeout mediates this)\" ;;
478
+ 126) FALLOW_FAIL_KIND=\"not executable\" ;;
479
+ 127) FALLOW_FAIL_KIND=\"not found\" ;;
480
+ *) FALLOW_FAIL_KIND=\"crashed\" ;;
481
+ esac
482
+ echo \"WARNING: fallow structural pre-pass failed (${FALLOW_FAIL_KIND}, exit ${FALLOW_EXIT}): ${FALLOW_STDERR_SUMMARY}\"
468
483
  FALLOW_JSON_PATH=\"\"
469
484
  else
470
485
  mv \"${FALLOW_JSON_PATH}.tmp\" \"$FALLOW_JSON_PATH\"
@@ -472,7 +487,7 @@ else
472
487
  fi
473
488
  ```
474
489
 
475
- On any failure of the structural pre-pass (binary missing, timeout, empty output, or unparseable JSON), the workflow continues with no `<structural_findings>` injection; the reviewer agent receives a normal review request.
490
+ On any failure of the structural pre-pass (binary missing at step 2, or an execution failure here — timeout, spawn failure, crash, empty output, or unparseable JSON), the workflow continues with no `<structural_findings>` injection; the reviewer agent receives a normal review request. The WARNING above names the failure KIND so a hard execution failure (e.g. a Windows `.cmd` spawn failure) is not mistaken for an absent optional dependency.
476
491
 
477
492
  4) Optional MCP bridge path (runtime-dependent):
478
493
  - If `FALLOW_MCP=true`, set reviewer input mode to MCP-backed structural findings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "main": ".opencode/plugins/gsd-core.js",
6
6
  "bin": {
@@ -2,10 +2,14 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * scripts/gen-registry.cjs — generates docs/registries/capability-registry.md
6
- * (and, once PR2 ships docs/registries/eos.json, docs/registries/eos-registry.md)
7
- * from the corresponding source JSON, via registry-schema.cjs#renderMarkdown.
8
- * Issue #2182.
5
+ * scripts/gen-registry.cjs — generates docs/registries/capability-registry.md,
6
+ * docs/registries/eos-registry.md, and docs/registries/reviewer-registry.md
7
+ * from their corresponding source JSON, via registry-schema.cjs#renderMarkdown.
8
+ * Issue #2182 (capability/eos); issue #2904 (reviewer).
9
+ *
10
+ * eos.json and reviewers.json are both OPTIONAL sources (`SOURCES[].optional`)
11
+ * — an absent one is skipped silently. capabilities.json is the primary
12
+ * source and is never optional.
9
13
  *
10
14
  * NOT to be confused with `scripts/gen-capability-registry.cjs`: that script
11
15
  * generates the RUNTIME capability manifest consumed by the host at runtime
@@ -34,7 +38,8 @@ const { renderMarkdown } = require('./registry-schema.cjs');
34
38
 
35
39
  const SOURCES = [
36
40
  { type: 'capability', jsonFile: 'capabilities.json', mdFile: 'capability-registry.md' },
37
- { type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md' },
41
+ { type: 'eos', jsonFile: 'eos.json', mdFile: 'eos-registry.md', optional: true },
42
+ { type: 'reviewer', jsonFile: 'reviewers.json', mdFile: 'reviewer-registry.md', optional: true },
38
43
  ];
39
44
 
40
45
  /**
@@ -57,14 +62,16 @@ function getRegistriesDir() {
57
62
  * Render the markdown for a single registry type from its committed source
58
63
  * JSON.
59
64
  *
60
- * Only `eos.json` is optional (pre-PR2, before that source JSON ships) —
61
- * an absent `eos.json` returns null and callers treat that as "nothing to
62
- * do". `capabilities.json` is the primary registry source: a missing
63
- * `capabilities.json` is ALWAYS an error (never a silent "up to date"
64
- * pass), mirroring the type distinction in `scripts/validate-registry.cjs`
65
- * (`type === 'eos' && !exists → continue`).
65
+ * Optionality is a per-source data flag (`SOURCES[].optional`), not a
66
+ * hardcoded type literal: `eos.json` (pre-PR2) and `reviewers.json` (issue
67
+ * #2904) are both optional — an absent source JSON returns null and callers
68
+ * treat that as "nothing to do". `capabilities.json` is still the primary
69
+ * registry source and is never optional: a missing `capabilities.json` is
70
+ * ALWAYS an error (never a silent "up to date" pass), mirroring the same
71
+ * `optional` flag in `scripts/validate-registry.cjs`
72
+ * (`optional && !exists → continue`).
66
73
  *
67
- * @param {'capability'|'eos'} type
74
+ * @param {'capability'|'eos'|'reviewer'} type
68
75
  * @returns {string|null}
69
76
  */
70
77
  function renderFor(type) {
@@ -73,14 +80,31 @@ function renderFor(type) {
73
80
 
74
81
  const jsonPath = path.join(getRegistriesDir(), source.jsonFile);
75
82
  if (!fs.existsSync(jsonPath)) {
76
- if (type === 'eos') return null;
83
+ if (source.optional) return null;
77
84
  throw new ExitError(
78
85
  1,
79
86
  `${source.jsonFile} does not exist at ${jsonPath}. Run:\n node scripts/gen-registry.cjs --write\n(after adding docs/registries/${source.jsonFile})`,
80
87
  );
81
88
  }
82
89
 
83
- const entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
90
+ // A malformed source JSON must surface as an actionable CLI error, not an
91
+ // unhandled SyntaxError with a raw Node stack trace — mirrors
92
+ // scripts/validate-registry.cjs#validateFile's try/catch around JSON.parse.
93
+ let entries;
94
+ try {
95
+ entries = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
96
+ } catch (err) {
97
+ throw new ExitError(1, `${source.jsonFile} is not valid JSON at ${jsonPath}: ${err.message}`);
98
+ }
99
+
100
+ // Mirrors validate-registry.cjs's explicit non-array rejection: a source
101
+ // JSON that parses to a non-array (object, string, etc.) would otherwise
102
+ // throw an opaque TypeError from `[...entries].sort()` in renderMarkdown,
103
+ // or silently mis-render for an iterable-but-wrong-shape value like a string.
104
+ if (!Array.isArray(entries)) {
105
+ throw new ExitError(1, `${source.jsonFile} must be a JSON array of entries`);
106
+ }
107
+
84
108
  return renderMarkdown(entries, { type, sourceFile: source.jsonFile });
85
109
  }
86
110
 
@@ -91,7 +115,7 @@ function main() {
91
115
 
92
116
  for (const { type, mdFile } of SOURCES) {
93
117
  const rendered = renderFor(type);
94
- if (rendered === null) continue; // source JSON absent (eos.json before PR2)
118
+ if (rendered === null) continue; // source JSON absent and optional (eos.json before PR2 / reviewers.json)
95
119
 
96
120
  const mdPath = path.join(registriesDir, mdFile);
97
121