@sdd-method/author-cli 0.16.0 → 0.16.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.
@@ -129,10 +129,17 @@ export async function orderArchives(sddPath, archiveRel, names) {
129
129
  }
130
130
  return { order, ties, history: history !== null };
131
131
  }
132
+ /**
133
+ * Whether the evidence's `**Observed in:**` line is the typed claim `none`: a baseline that admits no behaviour (no
134
+ * spec deltas), such as a structure-only removal, observed no repository serving any.
135
+ */
136
+ export function observedNone(evidence) {
137
+ return /^\s*\*\*Observed in:\*\*\s*none\b/im.test(evidence);
138
+ }
132
139
  /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
133
140
  export function observedIn(evidence) {
134
141
  const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
135
- if (!m)
142
+ if (!m || observedNone(evidence))
136
143
  return [];
137
144
  return m[1]
138
145
  .split(",")
@@ -148,18 +155,30 @@ export function observedIn(evidence) {
148
155
  * `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
149
156
  * Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
150
157
  * not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
158
+ *
159
+ * `**Surfaces:** none` on one line is the typed claim that the baseline lists no surface, which only a baseline that
160
+ * admits no behaviour may make (the caller decides; `none` is true and the list is empty).
151
161
  */
152
162
  export function parseSurfaces(evidence) {
153
163
  const lines = evidence.split("\n");
154
164
  // The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
155
165
  // the list is what the file ends up asserting.
156
166
  let at = -1;
167
+ let none = false;
157
168
  lines.forEach((l, i) => {
158
- if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
169
+ if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l)) {
159
170
  at = i;
171
+ none = false;
172
+ }
173
+ else if (/^\s*\*\*Surfaces:\*\*\s*none\b/i.test(l)) {
174
+ at = i;
175
+ none = true;
176
+ }
160
177
  });
161
178
  if (at < 0)
162
- return { present: false, entries: [], problems: [] };
179
+ return { present: false, none: false, entries: [], problems: [] };
180
+ if (none)
181
+ return { present: true, none: true, entries: [], problems: [] };
163
182
  const entries = [];
164
183
  const problems = [];
165
184
  for (const line of lines.slice(at + 1)) {
@@ -193,8 +212,8 @@ export function parseSurfaces(evidence) {
193
212
  }
194
213
  }
195
214
  if (entries.length + problems.length === 0)
196
- problems.push("the **Surfaces:** line lists no contract");
197
- return { present: true, entries, problems };
215
+ problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
216
+ return { present: true, none: false, entries, problems };
198
217
  }
199
218
  /**
200
219
  * A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
@@ -24,9 +24,18 @@
24
24
  //
25
25
  // Dedup ordering matters: walker (1) runs first (capability features),
26
26
  // then walker (2) (standalone specs), then walker (3) (bullets). When
27
- // a bullet's id is already in `seen` from a standalone spec the spec
28
- // wins — divergence finding fires for the bullet. This matches the
29
- // "standalone is more authoritative than bullet inventory" rule.
27
+ // a bullet's id is already claimed by a standalone app spec (2), the two
28
+ // are one feature and make one row (sdm-mbhi): the feature tree is the home
29
+ // of its placement and inventory facts (parent_group_id, maturity, runway,
30
+ // description), the spec of its lifecycle (status, kind), and source stays
31
+ // feature-spec, since a spec exists. A spec does not restate the tree's
32
+ // fields, and its H1 title is a name, not a description. A bullet whose id a
33
+ // capability feature (1) already claims is still a divergence.
34
+ //
35
+ // A standalone spec's stem is the feature's slug, without the product
36
+ // prefix: features/<group>/alerts.md is <productId>-alerts. A stem that
37
+ // already starts with "<productId>-" would double the prefix, so it is
38
+ // refused with a convention finding naming the stem to use.
30
39
  import { readdir, readFile } from "node:fs/promises";
31
40
  import { basename, join, relative, sep } from "node:path";
32
41
  import { featureGroupId, findFeatureTreeFile, walkFeatureTree, } from "./feature-tree-walker.js";
@@ -103,10 +112,15 @@ export async function buildFeatures(inputs) {
103
112
  // These features are owned by the SDD's product (not by a capability) —
104
113
  // capability_id is left empty; product_id carries the link.
105
114
  if (inputs.profile === "application-product") {
115
+ const appSpecRow = new Map();
106
116
  const appFeatures = await collectAppFeatureSpecs(inputs.sddPath);
107
117
  for (const path of appFeatures) {
108
118
  const relPath = relative(inputs.sddPath, path);
109
119
  const stem = basename(relPath).replace(/\.md$/i, "");
120
+ if (stem.startsWith(`${inputs.productId}-`)) {
121
+ inputs.findings.add("convention", `${relPath}: feature spec stem "${stem}" repeats the product prefix, so its id would read "${inputs.productId}-${stem}"; name the file after the feature's slug ("${stem.slice(inputs.productId.length + 1)}.md"), the slug feature-tree.md uses`);
122
+ continue;
123
+ }
110
124
  const id = `${inputs.productId}-${stem}`;
111
125
  if (seen.has(id)) {
112
126
  inputs.findings.add("divergence", `${relPath}: feature id "${id}" already emitted by an earlier file`);
@@ -133,6 +147,7 @@ export async function buildFeatures(inputs) {
133
147
  idByPath.set(relPath, id);
134
148
  if (!idBySlug.has(stem))
135
149
  idBySlug.set(stem, id);
150
+ appSpecRow.set(id, rows.length - 1);
136
151
  }
137
152
  // Bullet branch — feature-tree.md.
138
153
  await emitBulletRows({
@@ -143,6 +158,7 @@ export async function buildFeatures(inputs) {
143
158
  seen,
144
159
  rows,
145
160
  idBySlug,
161
+ appSpecRow,
146
162
  });
147
163
  }
148
164
  return { rows, idByPath, idBySlug };
@@ -178,7 +194,8 @@ async function emitBulletRows(ctx) {
178
194
  continue;
179
195
  bulletCount++;
180
196
  const id = `${ctx.productId}-${ev.slug}`;
181
- if (ctx.seen.has(id)) {
197
+ const specIndex = ctx.appSpecRow.get(id);
198
+ if (ctx.seen.has(id) && specIndex === undefined) {
182
199
  ctx.findings.add("divergence", `${relPath}: feature-tree.md bullet "${ev.slug}" superseded by an earlier feature spec with id "${id}"`);
183
200
  continue;
184
201
  }
@@ -196,6 +213,19 @@ async function emitBulletRows(ctx) {
196
213
  const description = ev.description.length > DESCRIPTION_MAX
197
214
  ? ev.description.slice(0, DESCRIPTION_MAX)
198
215
  : ev.description;
216
+ if (specIndex !== undefined) {
217
+ // One feature, one row: the tree's placement and inventory facts, the spec's lifecycle.
218
+ ctx.rows[specIndex] = {
219
+ ...ctx.rows[specIndex],
220
+ parent_group_id: parentGroupId,
221
+ description,
222
+ maturity: maturityFromRunway(ev.runway),
223
+ runway: ev.runway,
224
+ };
225
+ if (!ctx.idBySlug.has(ev.slug))
226
+ ctx.idBySlug.set(ev.slug, id);
227
+ continue;
228
+ }
199
229
  ctx.rows.push({
200
230
  id,
201
231
  name: ev.slug,
@@ -276,7 +276,7 @@ export async function buildRequirements(inputs) {
276
276
  structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
277
277
  if (evidence !== null) {
278
278
  const repos = observedIn(evidence);
279
- if (repos.length === 0) {
279
+ if (repos.length === 0 && delta.length > 0) {
280
280
  findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
281
281
  }
282
282
  for (const repo of repos)
@@ -6,7 +6,11 @@
6
6
  // - a member observed after the set date;
7
7
  // - a prior member absent from the export and not declared carried (with a
8
8
  // reason) or removed (with a reason) — never left to be inferred;
9
- // - a member re-exported at the same revision whose bytes differ;
9
+ // - a member re-exported at the same revision whose bytes differ, unless the only
10
+ // difference is files under a path declared --not-source (with a reason) that the
11
+ // prior export holds and the new one does not: analysis a tool wrote into the old
12
+ // export, not the repository. The prior baseline is never edited, the excused files
13
+ // and the reason are recorded, and a declaration that excuses nothing is refused;
10
14
  // - no recorded credential scan, or one with findings but no disposition —
11
15
  // a snapshot owner often cannot remediate upstream code, so findings land
12
16
  // with the disposition on the record, never as a false `clean`;
@@ -68,6 +72,17 @@ export async function land(inputs, write) {
68
72
  const s = splitFolder(m.exported_folder_name);
69
73
  return s ? [[s.stem, m.canonical_repo_key]] : [];
70
74
  }));
75
+ const notSource = new Map();
76
+ for (const [raw, reason] of inputs.notSource ?? []) {
77
+ const p = raw.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
78
+ if (p === "" || p.startsWith("/") || p.split("/").includes(".."))
79
+ throw new SnapshotError(`--not-source '${raw}' must be a relative path inside a member folder`);
80
+ if (!reason.trim())
81
+ throw new SnapshotError(`--not-source ${raw} needs a reason: --not-source <path>=<why it is not source>`);
82
+ notSource.set(p, reason.trim());
83
+ }
84
+ const declaredPath = (f) => [...notSource.keys()].find((p) => f === p || f.startsWith(`${p}/`));
85
+ const excusedBy = new Map();
71
86
  const known = inputs.sddPath ? await knownRepositoryIds(inputs.sddPath) : new Set();
72
87
  const keyProblems = [];
73
88
  const members = [];
@@ -105,18 +120,35 @@ export async function land(inputs, write) {
105
120
  const before = await hashTree(memberBytesDir(areaRoot, prior, p));
106
121
  const after = await hashTree(join(dir, folder));
107
122
  const d = diffTrees(before, after);
108
- const changed = d.added.length + d.removed.length + d.modified.length;
123
+ // Only a file the prior export holds and the new one lacks, under a declared path, is excused.
124
+ const excused = d.removed.filter((f) => declaredPath(f) !== undefined);
125
+ const changed = d.added.length + d.modified.length + d.removed.length - excused.length;
109
126
  if (changed > 0) {
110
- throw new SnapshotError(`${key} was re-exported at the same revision ${revision} as ${prior.snapshot_id}, but ${changed} file(s) differ — one of the two exports is not what its name says`);
127
+ throw new SnapshotError(`${key} was re-exported at the same revision ${revision} as ${prior.snapshot_id}, but ${changed} file(s) differ — one of the two exports is not what its name says` +
128
+ (notSource.size > 0 ? ` (--not-source excuses only files the prior export holds and this one does not, under a declared path)` : ""));
111
129
  }
112
130
  entry.reverified_at = md ?? id;
113
131
  entry.reverification = `re-exported at the same revision as ${prior.snapshot_id}; ${after.size} file(s) byte-identical`;
132
+ if (excused.length > 0) {
133
+ const byPath = new Map();
134
+ for (const f of excused) {
135
+ const p = declaredPath(f);
136
+ byPath.set(p, (byPath.get(p) ?? 0) + 1);
137
+ excusedBy.set(p, (excusedBy.get(p) ?? 0) + 1);
138
+ }
139
+ entry.reverification += `; ${[...byPath].map(([p, n]) => `${n} file(s) under ${p}/ in ${prior.snapshot_id} only, not source: ${notSource.get(p)}`).join("; ")}`;
140
+ write(`note: ${key}: ${excused.length} file(s) under a --not-source path excused (prior export only)\n`);
141
+ }
114
142
  }
115
143
  members.push(entry);
116
144
  }
117
145
  if (keyProblems.length > 0) {
118
146
  throw new SnapshotError(`${keyProblems.length} derived key(s) are not repository ids:\n - ${keyProblems.join("\n - ")}`);
119
147
  }
148
+ const unused = [...notSource.keys()].filter((p) => !excusedBy.has(p));
149
+ if (unused.length > 0) {
150
+ throw new SnapshotError(`--not-source ${unused.join(", ")} excused nothing: no same-revision member's prior export holds files there that this one lacks; drop the declaration`);
151
+ }
120
152
  const removed = [];
121
153
  const unaccounted = [];
122
154
  for (const p of prior?.members ?? []) {
@@ -838,6 +838,7 @@ export async function checkBehaviourStore(repo, options = {}) {
838
838
  openBaselines.push(change);
839
839
  const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
840
840
  let surfacesChecked = 0;
841
+ let structureOnly = 0;
841
842
  // The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
842
843
  // move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
843
844
  // archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
@@ -900,10 +901,35 @@ export async function checkBehaviourStore(repo, options = {}) {
900
901
  }
901
902
  return out;
902
903
  };
904
+ /**
905
+ * Whether a baseline admits behaviour: any requirement in its spec deltas. One that admits none (a structure-only
906
+ * baseline, such as the removal of structure carved into another SDD) observes no served behaviour, so it has no
907
+ * surface to account for, and says so with the typed claim "**Surfaces:** none".
908
+ */
909
+ const admitsBehaviour = async (dir) => {
910
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
911
+ if (parseRequirements((await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "").length > 0)
912
+ return true;
913
+ }
914
+ return false;
915
+ };
903
916
  /** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
904
917
  const surfaceProblems = async (dir, evidence, after = -1) => {
905
918
  const out = [];
906
919
  const parsed = parseSurfaces(evidence);
920
+ const admits = await admitsBehaviour(dir);
921
+ if (!admits) {
922
+ if (parsed.none) {
923
+ structureOnly++;
924
+ return out;
925
+ }
926
+ if (parsed.entries.length === 0) {
927
+ return [`${dir}/baseline-evidence.md: the baseline admits no behaviour (no requirement in its spec deltas), so no surface is observed: write "**Surfaces:** none" (and "**Observed in:** none" where no repository is observed)`];
928
+ }
929
+ }
930
+ else if (parsed.none) {
931
+ return [`${dir}/baseline-evidence.md: **Surfaces:** none, but the baseline admits behaviour (requirements in ${dir}/specs/); name every contract the observed repositories serve, covered at transport or excluded with a reason`];
932
+ }
907
933
  if (!parsed.present)
908
934
  out.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
909
935
  for (const p of parsed.problems)
@@ -1022,7 +1048,7 @@ export async function checkBehaviourStore(repo, options = {}) {
1022
1048
  if (openBaselines.length > 0)
1023
1049
  lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
1024
1050
  const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r)?.served ?? []).length === 0).join(", ") || "none"}`;
1025
- lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
1051
+ lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${structureOnly ? `; ${structureOnly} structure-only baseline(s) admitting no behaviour, Surfaces: none` : ""}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
1026
1052
  lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
1027
1053
  if (!history && archived.length > 0)
1028
1054
  lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
@@ -14,6 +14,9 @@
14
14
  // gherkin Given/When/Then lines anywhere else;
15
15
  // sections content lines under a heading that states behaviour: Happy Path, Edge Cases, Error Handling,
16
16
  // Business Rules, Validation Rules, Acceptance Criteria.
17
+ // A pointer is not a restatement: under a behaviour heading, a line naming `openspec/specs/<feature-id>/` is the
18
+ // pointer the template asks for there ("## Acceptance Criteria" with "shipped, see openspec/specs/…: <names>"), unless
19
+ // it is itself a criterion (a checkbox) or a Given/When/Then line.
17
20
  // Intent not yet shipped is allowed where it is marked. A heading reading "Not yet shipped or agreed" covers its
18
21
  // section, to the next heading of the same or a higher level. A bold line reading so covers what follows it, to the
19
22
  // next heading of any level. Open Questions' checkboxes are not
@@ -29,6 +32,8 @@ const NOT_YET = /not yet (shipped|agreed)/i;
29
32
  const CRITERIA_START = /^\*\*Acceptance Criteria:?\*\*/i;
30
33
  const GHERKIN = /^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?)?(?:\*\*)?(Given|When|Then)\b/;
31
34
  const LIST_LINE = /^\s*(?:[-*]|\d+\.)\s+\S/;
35
+ const POINTER_LINE = /openspec\/specs\/[^/\s`)]+/;
36
+ const CHECKBOX = /^\s*(?:[-*]|\d+\.)\s+\[[ xX]\]/;
32
37
  /** "## 3. Edge Cases (Revised)" -> "edge cases". */
33
38
  function headingKey(h) {
34
39
  return h.replace(/^\s*\d+(\.\d+)*\.?\s*/, "").replace(/\((revised|updated)\)\s*$/i, "").trim().toLowerCase();
@@ -72,6 +77,8 @@ export function scanRestated(text) {
72
77
  if (!line.trim() || /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line))
73
78
  continue; // blank, or a table separator
74
79
  if (stack.some((s) => BEHAVIOUR_HEADING.test(s.key))) {
80
+ if (POINTER_LINE.test(line) && !CHECKBOX.test(line) && !GHERKIN.test(line))
81
+ continue; // the pointer itself
75
82
  out.sections.push(n);
76
83
  continue;
77
84
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.17.0",
3
- "built_against_sdd_cli": "5.3.0"
2
+ "version": "0.17.2",
3
+ "built_against_sdd_cli": "5.3.2"
4
4
  }
@@ -72,6 +72,7 @@ export function registerSnapshot(program) {
72
72
  .option("--role-hint <key=role>", "role_hint for a member; continuing members otherwise keep the prior baseline's (repeatable)", collectPairs, new Map())
73
73
  .option("--credential-scan <record>", "tool=…,version=…,date=YYYY-MM-DD,result=clean — or result=<findings>,disposition=<what, and why it stands>")
74
74
  .option("--notes <text>", "coherence caveats for the manifest")
75
+ .option("--not-source <path=reason>", "a path inside member folders that is not source (a folder a tool wrote into the prior export), and why: a same-revision re-export may lack it, recorded on the member; nothing else is excused (repeatable)", collectPairs, new Map())
75
76
  .action(async (area, o) => {
76
77
  const scan = record(o.credentialScan);
77
78
  exit(await runSnapshotLand({
@@ -85,6 +86,7 @@ export function registerSnapshot(program) {
85
86
  removed: o.removed,
86
87
  keys: o.key,
87
88
  roleHints: o.roleHint,
89
+ notSource: o.notSource,
88
90
  ...(scan ? { credentialScan: scan } : {}),
89
91
  ...(o.notes !== undefined ? { notes: o.notes } : {}),
90
92
  }, out));
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "5.3.0",
3
- "built_against_sdd_cli": "5.3.0"
2
+ "version": "5.3.2",
3
+ "built_against_sdd_cli": "5.3.2"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/author-cli",
3
- "version": "0.16.0",
4
- "sddCliVersion": "5.3.0",
3
+ "version": "0.16.2",
4
+ "sddCliVersion": "5.3.2",
5
5
  "description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -8,10 +8,16 @@
8
8
  # 1. service-architecture.md is present
9
9
  # 2. feature-tree.md is present (may be at docs/ or repo root)
10
10
  # 3. app-platform-dependencies.yaml is present
11
- # 4. Feature spec files under features/*/ have required sections:
11
+ # 4. Feature spec files under features/ have required sections:
12
12
  # - ## Overview
13
- # - ## Acceptance Criteria
13
+ # - ## Acceptance Criteria (a prefix: the template's heading is
14
+ # "## Acceptance Criteria Summary"; once the feature ships it holds the
15
+ # pointer to its living spec, M-ADR 0221 §4.3)
14
16
  # - ## Platform Contract Dependencies
17
+ # 5. Feature specs sit at features/<feature>/<feature>.md, one directory per
18
+ # feature with its story files beside it (the app-feature-spec skill's
19
+ # path). A spec stored flat at features/<name>.md is reported as [WARN],
20
+ # advisory: it does not fail the run.
15
21
  #
16
22
  # Does NOT check:
17
23
  # - Glossary alignment (no domain glossary in app repos)
@@ -142,6 +148,7 @@ check_feature_specs() {
142
148
  local specs_found=0
143
149
  local specs_failed=0
144
150
  local specs_passed=0
151
+ local specs_flat=0
145
152
 
146
153
  # Discover feature specs under features/*/
147
154
  local feature_spec
@@ -157,6 +164,12 @@ check_feature_specs() {
157
164
  fi
158
165
  done
159
166
 
167
+ if [[ "$(dirname "$feature_spec")" == "$APP_ROOT/features" ]]; then
168
+ local name; name="$(basename "$feature_spec" .md)"
169
+ log_warn "$rel: stored flat under features/; a feature spec's path is features/${name}/${name}.md, one directory per feature with its story files beside it (app-feature-spec skill). Advisory: reported, not failed"
170
+ specs_flat=$((specs_flat + 1))
171
+ fi
172
+
160
173
  if [[ "$file_failures" -eq 0 ]]; then
161
174
  log_pass "$rel: all required sections present"
162
175
  specs_passed=$((specs_passed + 1))
@@ -172,6 +185,9 @@ check_feature_specs() {
172
185
  fi
173
186
 
174
187
  log_info "Group 2: $specs_passed/$specs_found feature specs passed, $specs_failed failed"
188
+ if [[ "$specs_flat" -gt 0 ]]; then
189
+ log_warn "Group 2: $specs_flat of $specs_found feature spec(s) stored flat under features/ (advisory)"
190
+ fi
175
191
  }
176
192
 
177
193
  # ──────────────────────────────────────────────────────────────────────────────