@encore-os/eos-spec 0.16.0 → 0.18.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 (67) hide show
  1. package/dist/commands/close-out.js +48 -2
  2. package/dist/commands/close-out.js.map +1 -1
  3. package/dist/commands/doctor.js +14 -5
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/index-spec.js +22 -4
  6. package/dist/commands/index-spec.js.map +1 -1
  7. package/dist/commands/next.d.ts +6 -0
  8. package/dist/commands/next.js +11 -1
  9. package/dist/commands/next.js.map +1 -1
  10. package/dist/commands/reconcile.js +6 -2
  11. package/dist/commands/reconcile.js.map +1 -1
  12. package/dist/commands/scorecard.js +51 -0
  13. package/dist/commands/scorecard.js.map +1 -1
  14. package/dist/commands/signoff.js +12 -1
  15. package/dist/commands/signoff.js.map +1 -1
  16. package/dist/commands/status-drift.d.ts +2 -0
  17. package/dist/commands/status-drift.js +35 -0
  18. package/dist/commands/status-drift.js.map +1 -0
  19. package/dist/commands/verify-acs.d.ts +25 -1
  20. package/dist/commands/verify-acs.js +118 -36
  21. package/dist/commands/verify-acs.js.map +1 -1
  22. package/dist/index.js +3 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/lib/body-requirements.d.ts +30 -0
  25. package/dist/lib/body-requirements.js +56 -0
  26. package/dist/lib/body-requirements.js.map +1 -0
  27. package/dist/lib/closeout-core.d.ts +15 -0
  28. package/dist/lib/closeout-core.js +42 -5
  29. package/dist/lib/closeout-core.js.map +1 -1
  30. package/dist/lib/config.d.ts +24 -2
  31. package/dist/lib/config.js.map +1 -1
  32. package/dist/lib/doctor-core.d.ts +17 -2
  33. package/dist/lib/doctor-core.js +50 -12
  34. package/dist/lib/doctor-core.js.map +1 -1
  35. package/dist/lib/frontmatter.d.ts +15 -0
  36. package/dist/lib/frontmatter.js +87 -3
  37. package/dist/lib/frontmatter.js.map +1 -1
  38. package/dist/lib/reconcile-core.d.ts +1 -1
  39. package/dist/lib/reconcile-core.js +10 -2
  40. package/dist/lib/reconcile-core.js.map +1 -1
  41. package/dist/lib/rtm-scorecard-run.d.ts +38 -0
  42. package/dist/lib/rtm-scorecard-run.js +70 -0
  43. package/dist/lib/rtm-scorecard-run.js.map +1 -0
  44. package/dist/lib/rtm-scorecard.d.ts +146 -0
  45. package/dist/lib/rtm-scorecard.js +194 -0
  46. package/dist/lib/rtm-scorecard.js.map +1 -0
  47. package/dist/lib/spec-pure.d.ts +38 -0
  48. package/dist/lib/spec-pure.js +162 -17
  49. package/dist/lib/spec-pure.js.map +1 -1
  50. package/dist/lib/specs.d.ts +20 -0
  51. package/dist/lib/specs.js +37 -1
  52. package/dist/lib/specs.js.map +1 -1
  53. package/dist/lib/status-drift-run.d.ts +9 -0
  54. package/dist/lib/status-drift-run.js +73 -0
  55. package/dist/lib/status-drift-run.js.map +1 -0
  56. package/dist/lib/status-drift.d.ts +43 -0
  57. package/dist/lib/status-drift.js +79 -0
  58. package/dist/lib/status-drift.js.map +1 -0
  59. package/dist/lib/verifies-backlinks.d.ts +13 -0
  60. package/dist/lib/verifies-backlinks.js +33 -0
  61. package/dist/lib/verifies-backlinks.js.map +1 -0
  62. package/dist/lib/version.d.ts +1 -1
  63. package/dist/lib/version.js +1 -1
  64. package/dist/pure.d.ts +2 -0
  65. package/dist/pure.js +6 -0
  66. package/dist/pure.js.map +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The FR/SC Requirements-Traceability-Matrix (RTM) scorecard — a NON-VACUOUS coverage
3
+ * derivation folded into the engine from encoreos's repo-local `scripts/specs/verify-acs.mjs`
4
+ * (issue #79). It answers "how much of this spec's Definition of Done (its functional
5
+ * requirements FR-N and success criteria SC-N) is actually proven?" — where "proven" is
6
+ * defined so a coverage-free corpus cannot read 100% (the vacuity guard):
7
+ *
8
+ * - an **FR** is verified iff it has ≥1 covering AC and every covering AC is verified,
9
+ * OR it carries its own proof. An FR with zero covering ACs and no own-proof is NOT
10
+ * verified — that is the property the vacuity guard pins.
11
+ * - an **SC** is met iff it references ≥1 AC, at least one referenced AC is verified,
12
+ * AND every NFR it gates is green. An SC that references no AC is NOT met.
13
+ *
14
+ * PURE (fs-free): it reads a spec's body text + already-parsed `acceptance_criteria`, so it
15
+ * sits on the `/pure` graph and a browser bundle (Spec Studio) can compute the scorecard
16
+ * client-side. The corpus walk + file IO that feed it stay in the command layer. Trace edges
17
+ * (AC→FR via `fr_refs`, SC→AC/NFR via reference parens) mirror the engine's existing model.
18
+ */
19
+ import { matchBodyRequirement } from './body-requirements.js';
20
+ export { matchBodyRequirement };
21
+ /** An AC block the scorecard reasons over: its id, terminal status, and the FR/NFR ids it
22
+ * covers (from frontmatter `fr_refs` / `nfr_refs`). Sourced from the parsed frontmatter. */
23
+ export type ScorecardAcBlock = {
24
+ id: string;
25
+ status?: string;
26
+ fr_refs?: string[];
27
+ nfr_refs?: string[];
28
+ };
29
+ /** A body requirement/NFR item (`- **FR-1** [status] …`). */
30
+ export type ScorecardRequirementItem = {
31
+ id: string;
32
+ status: string;
33
+ };
34
+ /** A success criterion parsed from the body, with the ACs/NFRs it traces to. */
35
+ export type SuccessCriterionItem = {
36
+ id: string;
37
+ status: string;
38
+ ac_refs: string[];
39
+ nfr_refs: string[];
40
+ };
41
+ /** Proof accumulated from a verification run: AC/FR ids proven by a passing backing test.
42
+ * Empty ⇒ the scorecard reflects on-disk statuses only (still non-vacuous). */
43
+ export type ScorecardProof = {
44
+ provenAcIds?: Set<string>;
45
+ provenFrIds?: Set<string>;
46
+ };
47
+ export type FrVerdict = {
48
+ verified: boolean;
49
+ reason: string;
50
+ coveringAcs: string[];
51
+ };
52
+ export type ScVerdictResult = {
53
+ met: boolean;
54
+ reason: string;
55
+ };
56
+ export type Scorecard = {
57
+ fr: {
58
+ total: number;
59
+ verified: number;
60
+ rows: Array<{
61
+ id: string;
62
+ } & FrVerdict>;
63
+ };
64
+ sc: {
65
+ total: number;
66
+ met: number;
67
+ rows: Array<{
68
+ id: string;
69
+ } & ScVerdictResult>;
70
+ };
71
+ };
72
+ /** Parse one body line into a success criterion `{ id, status, ac_refs, nfr_refs }`, or null.
73
+ * SC lines carry trailing reference parens — `(US-3, AC-6)`, `(US-1, FR-1, NFR-1)` — from
74
+ * which the AC and NFR ids are scraped (dedup'd). */
75
+ export declare function matchSuccessCriterion(line: string): SuccessCriterionItem | null;
76
+ /** Match a body NFR item (`- **NFR-perf-1** [status] …`) — the gates an SC verdict checks. */
77
+ export declare function matchNfrItem(line: string): ScorecardRequirementItem | null;
78
+ /** Invert the AC→FR trace edges (each AC's `fr_refs`) into `FR id → [covering AC ids]`. */
79
+ export declare function coveringAcsByFr(acBlocks: ScorecardAcBlock[]): Map<string, string[]>;
80
+ /** True if an AC is verified — status `verified` on disk OR proven in this run. */
81
+ export declare function isAcVerified(acId: string, acStatusById: Map<string, string | undefined>, provenAcIds: Set<string>): boolean;
82
+ /**
83
+ * The NON-VACUOUS FR verdict. An FR counts verified iff (a) it has ≥1 covering AC and every
84
+ * covering AC is verified, or (b) the FR itself is proven. Zero covering ACs and no own-proof
85
+ * ⇒ NOT verified (the vacuity guard: a coverage-free corpus cannot read 100%).
86
+ */
87
+ export declare function frVerdict(opts: {
88
+ coveringAcs?: string[];
89
+ acStatusById: Map<string, string | undefined>;
90
+ provenAcIds: Set<string>;
91
+ frProven?: boolean;
92
+ frVerifiedOnDisk?: boolean;
93
+ }): FrVerdict;
94
+ /**
95
+ * The NON-VACUOUS SC verdict. An SC counts met iff (a) it references ≥1 AC and at least one
96
+ * referenced AC is verified, and (b) every NFR it gates is green. A missing NFR (not in the
97
+ * spec) is ungreen — a dangling gate cannot be assumed satisfied. An SC referencing no AC is
98
+ * NOT met.
99
+ */
100
+ export declare function scVerdict(sc: SuccessCriterionItem, opts: {
101
+ acStatusById: Map<string, string | undefined>;
102
+ provenAcIds: Set<string>;
103
+ nfrVerifiedById: Map<string, boolean>;
104
+ }): ScVerdictResult;
105
+ /** The items one spec's scorecard reasons over (body-parsed FR/SC/NFR + frontmatter AC blocks). */
106
+ export type ScorecardItems = {
107
+ frItems: ScorecardRequirementItem[];
108
+ acBlocks: ScorecardAcBlock[];
109
+ scItems: SuccessCriterionItem[];
110
+ nfrItems: ScorecardRequirementItem[];
111
+ };
112
+ /**
113
+ * Collect the scorecard items for one spec from its BODY text (FR/US/NFR/SC list lines) and
114
+ * its already-parsed `acceptance_criteria` frontmatter (AC id/status/fr_refs/nfr_refs). Pure —
115
+ * the caller supplies the body (frontmatter-stripped) and the raw AC array. US items are not
116
+ * scored (they trace via ACs) so they are not returned.
117
+ */
118
+ export declare function collectScorecardItems(bodyText: string, rawAcs: unknown): ScorecardItems;
119
+ /** Derive the whole FR/SC scorecard for one spec — the single non-vacuous entry point. */
120
+ export declare function deriveScorecard(spec: Partial<ScorecardItems>, proof?: ScorecardProof): Scorecard;
121
+ /** Advisory coverage ratchet floors (warn-level, NEVER fatal): the fraction of FRs that clear
122
+ * the non-vacuous bar and of SCs that are met. Conservative defaults; a consumer overrides via
123
+ * `rtm.ratchets` in `specs/config.yaml`. Kept advisory so a young/low-coverage corpus stays
124
+ * green while a real regression below the floor still surfaces. */
125
+ export declare const COVERAGE_RATCHET_FLOORS: Record<string, number>;
126
+ /** The resolved RTM config: whether the scorecard is enabled + the effective ratchet floors.
127
+ * `resolveRtm` duck-types the config (no fs-bound `SpecConfig` import) so this stays pure. */
128
+ export type RtmConfig = {
129
+ enabled: boolean;
130
+ ratchets: Record<string, number>;
131
+ };
132
+ /** Resolve the opt-in RTM scorecard config; defaults to disabled + the built-in floors, so a
133
+ * repo without an `rtm:` block (or with `enabled` falsy) gets no scorecard. */
134
+ export declare function resolveRtm(config: {
135
+ rtm?: {
136
+ enabled?: boolean;
137
+ ratchets?: Record<string, number>;
138
+ };
139
+ } | null | undefined): RtmConfig;
140
+ /** Compare measured coverage (`{ frVerifiedPct, scMetPct, … }`, each 0–1) against the advisory
141
+ * floors. Returns the breaches (empty ⇒ none). Pure — the caller decides whether to print/warn. */
142
+ export declare function checkCoverageRatchets(measured: Record<string, number>, floors?: Record<string, number>): Array<{
143
+ metric: string;
144
+ floor: number;
145
+ value: number;
146
+ }>;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The FR/SC Requirements-Traceability-Matrix (RTM) scorecard — a NON-VACUOUS coverage
3
+ * derivation folded into the engine from encoreos's repo-local `scripts/specs/verify-acs.mjs`
4
+ * (issue #79). It answers "how much of this spec's Definition of Done (its functional
5
+ * requirements FR-N and success criteria SC-N) is actually proven?" — where "proven" is
6
+ * defined so a coverage-free corpus cannot read 100% (the vacuity guard):
7
+ *
8
+ * - an **FR** is verified iff it has ≥1 covering AC and every covering AC is verified,
9
+ * OR it carries its own proof. An FR with zero covering ACs and no own-proof is NOT
10
+ * verified — that is the property the vacuity guard pins.
11
+ * - an **SC** is met iff it references ≥1 AC, at least one referenced AC is verified,
12
+ * AND every NFR it gates is green. An SC that references no AC is NOT met.
13
+ *
14
+ * PURE (fs-free): it reads a spec's body text + already-parsed `acceptance_criteria`, so it
15
+ * sits on the `/pure` graph and a browser bundle (Spec Studio) can compute the scorecard
16
+ * client-side. The corpus walk + file IO that feed it stay in the command layer. Trace edges
17
+ * (AC→FR via `fr_refs`, SC→AC/NFR via reference parens) mirror the engine's existing model.
18
+ */
19
+ import { matchBodyRequirement } from './body-requirements.js';
20
+ // Re-export the body-requirement parser for backward compatibility (and convenience).
21
+ export { matchBodyRequirement };
22
+ /** Parse one body line into a success criterion `{ id, status, ac_refs, nfr_refs }`, or null.
23
+ * SC lines carry trailing reference parens — `(US-3, AC-6)`, `(US-1, FR-1, NFR-1)` — from
24
+ * which the AC and NFR ids are scraped (dedup'd). */
25
+ export function matchSuccessCriterion(line) {
26
+ const m = /^\s*-\s+\*\*(SC-\d+(?:\.\d+)*)\b[^*\n]*\*\*\s*(?:\[([a-z_]+)\])?\s*(.*)$/.exec(line);
27
+ if (!m)
28
+ return null;
29
+ const tail = m[3] ?? '';
30
+ return {
31
+ id: m[1],
32
+ status: m[2] ?? 'planned',
33
+ ac_refs: [...new Set(tail.match(/\bAC-\d+(?:\.\d+)*/g) ?? [])],
34
+ nfr_refs: [...new Set(tail.match(/\bNFR(?:-[a-z]+)?-\d+(?:\.\d+)*/g) ?? [])],
35
+ };
36
+ }
37
+ /** Match a body NFR item (`- **NFR-perf-1** [status] …`) — the gates an SC verdict checks. */
38
+ export function matchNfrItem(line) {
39
+ const m = /^\s*-\s+\*{0,2}(NFR(?:-[a-z]+)?-\d+(?:\.\d+)*)\*{0,2}(?:\s+\[([a-z_]+)\])?/.exec(line);
40
+ return m ? { id: m[1], status: m[2] ?? 'planned' } : null;
41
+ }
42
+ /** Invert the AC→FR trace edges (each AC's `fr_refs`) into `FR id → [covering AC ids]`. */
43
+ export function coveringAcsByFr(acBlocks) {
44
+ const map = new Map();
45
+ for (const ac of acBlocks) {
46
+ for (const fr of ac.fr_refs ?? []) {
47
+ if (!map.has(fr))
48
+ map.set(fr, []);
49
+ map.get(fr).push(ac.id);
50
+ }
51
+ }
52
+ return map;
53
+ }
54
+ /** True if an AC is verified — status `verified` on disk OR proven in this run. */
55
+ export function isAcVerified(acId, acStatusById, provenAcIds) {
56
+ return acStatusById.get(acId) === 'verified' || provenAcIds.has(acId);
57
+ }
58
+ /**
59
+ * The NON-VACUOUS FR verdict. An FR counts verified iff (a) it has ≥1 covering AC and every
60
+ * covering AC is verified, or (b) the FR itself is proven. Zero covering ACs and no own-proof
61
+ * ⇒ NOT verified (the vacuity guard: a coverage-free corpus cannot read 100%).
62
+ */
63
+ export function frVerdict(opts) {
64
+ const { coveringAcs = [], acStatusById, provenAcIds, frProven = false, frVerifiedOnDisk = false } = opts;
65
+ if (frProven || frVerifiedOnDisk) {
66
+ return { verified: true, reason: 'own @verifies proof', coveringAcs };
67
+ }
68
+ if (coveringAcs.length === 0) {
69
+ return { verified: false, reason: 'no covering AC and no own proof', coveringAcs };
70
+ }
71
+ const unverified = coveringAcs.filter((ac) => !isAcVerified(ac, acStatusById, provenAcIds));
72
+ if (unverified.length === 0) {
73
+ return { verified: true, reason: `${coveringAcs.length} covering AC(s) all verified`, coveringAcs };
74
+ }
75
+ return { verified: false, reason: `covering AC(s) not yet verified: ${unverified.join(', ')}`, coveringAcs };
76
+ }
77
+ /**
78
+ * The NON-VACUOUS SC verdict. An SC counts met iff (a) it references ≥1 AC and at least one
79
+ * referenced AC is verified, and (b) every NFR it gates is green. A missing NFR (not in the
80
+ * spec) is ungreen — a dangling gate cannot be assumed satisfied. An SC referencing no AC is
81
+ * NOT met.
82
+ */
83
+ export function scVerdict(sc, opts) {
84
+ const { acStatusById, provenAcIds, nfrVerifiedById } = opts;
85
+ const acRefs = sc.ac_refs ?? [];
86
+ const nfrRefs = sc.nfr_refs ?? [];
87
+ if (acRefs.length === 0) {
88
+ return { met: false, reason: 'references no AC (cannot be proven)' };
89
+ }
90
+ const verifiedAcs = acRefs.filter((ac) => isAcVerified(ac, acStatusById, provenAcIds));
91
+ if (verifiedAcs.length === 0) {
92
+ return { met: false, reason: `no referenced AC is verified (${acRefs.join(', ')})` };
93
+ }
94
+ const ungreenNfrs = nfrRefs.filter((nfr) => nfrVerifiedById.get(nfr) !== true);
95
+ if (ungreenNfrs.length > 0) {
96
+ return { met: false, reason: `NFR gate(s) not green: ${ungreenNfrs.join(', ')}` };
97
+ }
98
+ return { met: true, reason: `${verifiedAcs.length}/${acRefs.length} referenced AC(s) verified, NFR gates green` };
99
+ }
100
+ /**
101
+ * Collect the scorecard items for one spec from its BODY text (FR/US/NFR/SC list lines) and
102
+ * its already-parsed `acceptance_criteria` frontmatter (AC id/status/fr_refs/nfr_refs). Pure —
103
+ * the caller supplies the body (frontmatter-stripped) and the raw AC array. US items are not
104
+ * scored (they trace via ACs) so they are not returned.
105
+ */
106
+ export function collectScorecardItems(bodyText, rawAcs) {
107
+ const acBlocks = Array.isArray(rawAcs)
108
+ ? rawAcs
109
+ .filter((e) => !!e && typeof e === 'object')
110
+ .map((e) => ({
111
+ id: typeof e.id === 'string' ? e.id : '',
112
+ status: typeof e.status === 'string' ? e.status : undefined,
113
+ fr_refs: coerceIdList(e.fr_refs),
114
+ nfr_refs: coerceIdList(e.nfr_refs),
115
+ }))
116
+ .filter((a) => a.id !== '')
117
+ : [];
118
+ const frItems = [];
119
+ const nfrItems = [];
120
+ const scItems = [];
121
+ for (const line of bodyText.split('\n')) {
122
+ const req = matchBodyRequirement(line);
123
+ if (req) {
124
+ if (req.kind === 'FR')
125
+ frItems.push({ id: req.id, status: req.status });
126
+ continue; // US items are traced via ACs, not scored directly
127
+ }
128
+ const nfr = matchNfrItem(line);
129
+ if (nfr) {
130
+ nfrItems.push(nfr);
131
+ continue;
132
+ }
133
+ const sc = matchSuccessCriterion(line);
134
+ if (sc)
135
+ scItems.push(sc);
136
+ }
137
+ return { frItems, acBlocks, scItems, nfrItems };
138
+ }
139
+ /** Coerce a frontmatter ref field (`fr_refs`/`nfr_refs`) to a string id list — accepts a YAML
140
+ * array of strings or a single string; anything else ⇒ []. */
141
+ function coerceIdList(v) {
142
+ if (typeof v === 'string')
143
+ return v.trim() === '' ? [] : [v.trim()];
144
+ if (Array.isArray(v))
145
+ return v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim());
146
+ return [];
147
+ }
148
+ /** Derive the whole FR/SC scorecard for one spec — the single non-vacuous entry point. */
149
+ export function deriveScorecard(spec, proof = {}) {
150
+ const { frItems = [], acBlocks = [], scItems = [], nfrItems = [] } = spec;
151
+ const provenAcIds = proof.provenAcIds ?? new Set();
152
+ const provenFrIds = proof.provenFrIds ?? new Set();
153
+ const acStatusById = new Map(acBlocks.map((a) => [a.id, a.status]));
154
+ const nfrVerifiedById = new Map(nfrItems.map((n) => [n.id, n.status === 'verified']));
155
+ const coveringByFr = coveringAcsByFr(acBlocks);
156
+ const frRows = frItems.map((fr) => ({
157
+ id: fr.id,
158
+ ...frVerdict({
159
+ coveringAcs: coveringByFr.get(fr.id) ?? [],
160
+ acStatusById,
161
+ provenAcIds,
162
+ frProven: provenFrIds.has(fr.id),
163
+ frVerifiedOnDisk: fr.status === 'verified',
164
+ }),
165
+ }));
166
+ const scRows = scItems.map((sc) => ({ id: sc.id, ...scVerdict(sc, { acStatusById, provenAcIds, nfrVerifiedById }) }));
167
+ return {
168
+ fr: { total: frRows.length, verified: frRows.filter((r) => r.verified).length, rows: frRows },
169
+ sc: { total: scRows.length, met: scRows.filter((r) => r.met).length, rows: scRows },
170
+ };
171
+ }
172
+ /** Advisory coverage ratchet floors (warn-level, NEVER fatal): the fraction of FRs that clear
173
+ * the non-vacuous bar and of SCs that are met. Conservative defaults; a consumer overrides via
174
+ * `rtm.ratchets` in `specs/config.yaml`. Kept advisory so a young/low-coverage corpus stays
175
+ * green while a real regression below the floor still surfaces. */
176
+ export const COVERAGE_RATCHET_FLOORS = { frVerifiedPct: 0, scMetPct: 0 };
177
+ /** Resolve the opt-in RTM scorecard config; defaults to disabled + the built-in floors, so a
178
+ * repo without an `rtm:` block (or with `enabled` falsy) gets no scorecard. */
179
+ export function resolveRtm(config) {
180
+ const rtm = config?.rtm;
181
+ return { enabled: rtm?.enabled === true, ratchets: rtm?.ratchets ?? COVERAGE_RATCHET_FLOORS };
182
+ }
183
+ /** Compare measured coverage (`{ frVerifiedPct, scMetPct, … }`, each 0–1) against the advisory
184
+ * floors. Returns the breaches (empty ⇒ none). Pure — the caller decides whether to print/warn. */
185
+ export function checkCoverageRatchets(measured, floors = COVERAGE_RATCHET_FLOORS) {
186
+ const breaches = [];
187
+ for (const [metric, floor] of Object.entries(floors)) {
188
+ const value = measured[metric];
189
+ if (typeof value === 'number' && value < floor)
190
+ breaches.push({ metric, floor, value });
191
+ }
192
+ return breaches;
193
+ }
194
+ //# sourceMappingURL=rtm-scorecard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rtm-scorecard.js","sourceRoot":"","sources":["../../src/lib/rtm-scorecard.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,22 @@ export declare const SPEC_ID_GRAMMAR: {
8
8
  readonly pattern: "<core>(-[A-Z]{1,4})?-<n>(.<n>)?(-(EN-<n>|E<n>|P<n>(.<n>)?(-EN-<n>)?))?";
9
9
  readonly caseInsensitive: true;
10
10
  };
11
+ /**
12
+ * Remove regions that legitimately QUOTE compliance-sounding words as examples,
13
+ * so the keyword heuristic in {@link detectSensitive} reads only load-bearing
14
+ * prose. Strips, per line: fenced code blocks (``` … ``` / ~~~ … ~~~), a
15
+ * `## Examples` heading section — an EXACT `Examples`/`Example` heading title only
16
+ * (so `## Examples of Regulated Data` is a normal heading, its body left as
17
+ * scannable prose) — down to the next same-or-higher heading, and Gherkin step
18
+ * lines. `Scenario:` / `Example(s):` / `Given` / `When` / `Then` openers strip
19
+ * unconditionally; `And` / `But` strip ONLY when the immediately preceding
20
+ * non-blank line (no intervening blank line) was itself a Gherkin step/opener —
21
+ * a standalone `And`/`But` prose line (e.g. a paragraph continuation) is left in
22
+ * place. Pure string transform — no fs, stays on the `/pure` graph. Callers apply
23
+ * it to the keyword-match INPUT only; opt-in/opt-out markers are still read from
24
+ * the raw text.
25
+ */
26
+ export declare function stripExampleRegions(text: string): string;
11
27
  /** True when a generated test source has ≥1 ACTIVE `test(` block (not `test.fixme`/
12
28
  * `test.describe`) and is not an AC-less placeholder. Pure (operates on source text). */
13
29
  export declare function hasActiveGeneratedTestSource(source: string): boolean;
@@ -88,6 +104,28 @@ export declare function declaredSpecId(declaredId: string | null | undefined, co
88
104
  * Returns the resolved spec id, or null when the file is not a primary spec.
89
105
  */
90
106
  export declare function resolveSpecFileId(core: string, filename: string, declaredId?: string | null): string | null;
107
+ /**
108
+ * True when an on-disk filename PARSES to a well-formed primary-spec id yet the corpus
109
+ * loader would silently DROP it ({@link resolveSpecFileId} → null) for a reason a
110
+ * frontmatter `id:` could rescue — a reserved lifecycle word (REVIEW / ANALYSIS /
111
+ * CLARIFICATION …) landed inside a real feature slug and no `id:` disambiguates it. This
112
+ * is the silent-authoring-drop class issue #76 surfaces: the file reads as spec X to a
113
+ * human but never reaches spec-index.json / navigator / reconcile / recall.
114
+ *
115
+ * Deliberately EXCLUDES bona-fide sibling artifacts that legitimately carry a parent id
116
+ * and are SUPPOSED to be non-primary, so the surfacer stays quiet on a healthy corpus:
117
+ * - dedicated CONTEXT/CLARIFICATION/ENHANCEMENTS/EXPANSION files ({@link isDedicatedArtifactFile}),
118
+ * - `{ID}-PHASE…` lifecycle files ({@link isPhaseLifecycleFile}),
119
+ * - descriptor-suffix artifacts (`-TASKS`/`-PLAN`/`-RESEARCH`/… — {@link DERIVATIVE_DESCRIPTOR_RE}),
120
+ * - and a LONE terminal descriptor (`{ID}-REVIEW`, `{ID}-ANALYSIS`): only files with real
121
+ * slug content AFTER the id (≥2 slug words) are flagged, i.e. the reserved word is
122
+ * embedded in a feature name (`{ID}-compliance-review-workflow`), not the whole slug.
123
+ *
124
+ * The residue is exactly the case the mint-time guard (spec-id-guard → {@link resolveSpecFileId})
125
+ * blocks on `create`/`promote`, made visible for files already on disk. Pure (fs-free); the
126
+ * fs walk that feeds it lives in specs.ts `findSkippedSpecFiles`.
127
+ */
128
+ export declare function isSilentlySkippedSpecFile(core: string, filename: string, declaredId?: string | null): boolean;
91
129
  /** The skip rules `collectSpecsFromDirectory` applies to one filename. */
92
130
  export declare function isPrimarySpecFile(filename: string): boolean;
93
131
  /**
@@ -27,27 +27,125 @@ export const SPEC_ID_GRAMMAR = {
27
27
  };
28
28
  /** The marker the generator emits for an AC-less spec. */
29
29
  const GENERATED_NO_AC_MARKER = 'no acceptance_criteria — run encore-spec-ac-draft first';
30
- /** Keywords that mark a spec as carrying sensitive data — PHI/PII/billing/
31
- * credentialing. The `/spec-pipeline` step-3b compliance gate fires on these
32
- * mentions in ANY core (not just the regulated `cl/pm/hr/rh/gr` set), so the
33
- * engine must detect them to gate `hasComplianceForSensitive` at parity with the
34
- * command prose. Matched at word boundaries, case-insensitively, over frontmatter
35
- * + body. Kept to high-signal regime tokens so an incidental mention never trips
36
- * it; a spec can also opt in explicitly via a `<!-- sensitive -->`/`<!-- sensitive: -->`
37
- * marker or a `sensitive`/`phi`/`pii`/`billing`/`credentialing` tag. */
38
- const SENSITIVE_KEYWORD_RE = /\b(PHI|PII|protected health information|personally identifiable information|billing|claims?|reimbursement|remittance advice|credentialing|payer enrollment|HIPAA|42 CFR Part 2)\b/i;
39
- /** True when the spec text (frontmatter + body) signals sensitive data: a
40
- * high-signal regime keyword, an explicit `<!-- sensitive -->` marker, or a
41
- * `sensitive`/`phi`/`pii`/`billing`/`credentialing` tag. Pure (operates on the raw
42
- * spec text + the parsed `tags` array). Independent of `isRegulated` a non-
43
- * regulated FA billing or PF credentialing spec is `isSensitive` without being a
44
- * regulated core, which is exactly the parity gap Finding A item 2 names. */
30
+ /**
31
+ * Remove regions that legitimately QUOTE compliance-sounding words as examples,
32
+ * so the keyword heuristic in {@link detectSensitive} reads only load-bearing
33
+ * prose. Strips, per line: fenced code blocks (``` ``` / ~~~ … ~~~), a
34
+ * `## Examples` heading section an EXACT `Examples`/`Example` heading title only
35
+ * (so `## Examples of Regulated Data` is a normal heading, its body left as
36
+ * scannable prose) down to the next same-or-higher heading, and Gherkin step
37
+ * lines. `Scenario:` / `Example(s):` / `Given` / `When` / `Then` openers strip
38
+ * unconditionally; `And` / `But` strip ONLY when the immediately preceding
39
+ * non-blank line (no intervening blank line) was itself a Gherkin step/opener —
40
+ * a standalone `And`/`But` prose line (e.g. a paragraph continuation) is left in
41
+ * place. Pure string transform — no fs, stays on the `/pure` graph. Callers apply
42
+ * it to the keyword-match INPUT only; opt-in/opt-out markers are still read from
43
+ * the raw text.
44
+ */
45
+ export function stripExampleRegions(text) {
46
+ const out = [];
47
+ let inFence = false;
48
+ let exampleHeadingDepth = 0; // 0 ⇒ not inside a `## Examples` section
49
+ let inGherkinStep = false; // true right after a line that opened/continued a Gherkin step sequence
50
+ for (const line of text.split('\n')) {
51
+ const trimmed = line.trim();
52
+ if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
53
+ inFence = !inFence;
54
+ inGherkinStep = false;
55
+ continue;
56
+ }
57
+ if (inFence)
58
+ continue;
59
+ const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
60
+ if (heading) {
61
+ inGherkinStep = false;
62
+ const depth = heading[1].length;
63
+ if (exampleHeadingDepth && depth <= exampleHeadingDepth)
64
+ exampleHeadingDepth = 0;
65
+ if (!exampleHeadingDepth && /^\s*examples?\s*$/i.test(heading[2])) {
66
+ exampleHeadingDepth = depth;
67
+ continue;
68
+ }
69
+ if (exampleHeadingDepth)
70
+ continue;
71
+ out.push(line);
72
+ continue;
73
+ }
74
+ if (exampleHeadingDepth) {
75
+ inGherkinStep = false;
76
+ continue;
77
+ }
78
+ if (/^(Scenario|Examples?|Given|When|Then)\b/i.test(trimmed)) {
79
+ inGherkinStep = true;
80
+ continue;
81
+ }
82
+ if (inGherkinStep && /^(And|But)\b/i.test(trimmed))
83
+ continue;
84
+ inGherkinStep = false;
85
+ out.push(line);
86
+ }
87
+ return out.join('\n');
88
+ }
89
+ /** Compliance keyword matcher, split by signal strength. Matched at word boundaries,
90
+ * case-insensitively. HIGH-signal tokens are checked against the RAW text (a regime
91
+ * token is meaningful wherever it sits, even inside an example); LOW-signal tokens are
92
+ * checked against the EXAMPLE-STRIPPED text (see {@link stripExampleRegions}) so a
93
+ * billing/claims word quoted inside a Gherkin or fenced example on a non-regulated spec
94
+ * no longer trips the compliance gate. See {@link detectSensitive} for exactly which
95
+ * text each regex runs over.
96
+ *
97
+ * HIGH-signal tokens name a regulated regime directly, so a SINGLE hit flags the
98
+ * spec. The three low-signal tokens are common non-regulated business words, so
99
+ * they flag only on ≥2 DISTINCT token TYPES (see {@link countDistinctLowSignalTypes});
100
+ * two hits of the SAME token do not qualify. A spec can still opt in/out explicitly
101
+ * (see {@link detectSensitive}). */
102
+ const HIGH_SIGNAL_RE = /\b(PHI|PII|protected health information|personally identifiable information|remittance advice|credentialing|payer enrollment|HIPAA|42 CFR Part 2)\b/i;
103
+ /** Low-signal compliance tokens — global so {@link countDistinctLowSignalTypes} can
104
+ * enumerate every occurrence. */
105
+ const LOW_SIGNAL_RE = /\b(billing|claims?|reimbursement)\b/gi;
106
+ /** Count of DISTINCT low-signal token types in `text` (`claim`/`claims` collapse to
107
+ * one type). ≥2 is the low-signal fire threshold. */
108
+ function countDistinctLowSignalTypes(text) {
109
+ const types = new Set();
110
+ for (const match of text.matchAll(LOW_SIGNAL_RE)) {
111
+ const token = match[1].toLowerCase();
112
+ types.add(token.startsWith('claim') ? 'claims' : token);
113
+ }
114
+ return types.size;
115
+ }
116
+ /** True when the spec text (frontmatter + body) signals sensitive data. Precedence:
117
+ * an explicit opt-IN wins TRUE (an anchored `<!-- sensitive -->`/`<!-- sensitive: -->`
118
+ * marker or a `sensitive`/`phi`/`pii`/`billing`/`credentialing` tag), then an explicit
119
+ * opt-OUT wins FALSE (an anchored `<!-- not-sensitive -->` marker or a `not-sensitive`
120
+ * tag), otherwise the keyword heuristic: a HIGH-signal hit against the RAW text (so a
121
+ * regime token cannot be hidden by sitting on a Given/When/Then/Scenario/Examples opener
122
+ * line that {@link stripExampleRegions} would otherwise strip — a hard-gate fail-open),
123
+ * OR ≥2 distinct LOW-signal token types against the EXAMPLE-STRIPPED text (so ordinary
124
+ * billing/claims prose quoted in a Gherkin/fenced example does not false-positive).
125
+ * Marker/tag detection runs on the RAW text (a deliberate marker anywhere is intentional)
126
+ * BEFORE any stripping; the markers stay fully anchored so `not-sensitive` can never
127
+ * substring-match `sensitive`. Pure; independent of `isRegulated`. */
45
128
  function detectSensitive(text, tags) {
46
129
  if (/<!--\s*sensitive\s*(?::[^>]*)?-->/i.test(text))
47
130
  return true;
48
131
  if (tags.some((t) => /^(sensitive|phi|pii|billing|credentialing)$/i.test(String(t).trim())))
49
132
  return true;
50
- return SENSITIVE_KEYWORD_RE.test(text);
133
+ if (/<!--\s*not-sensitive\s*(?::[^>]*)?-->/i.test(text))
134
+ return false;
135
+ if (tags.some((t) => /^not-sensitive$/i.test(String(t).trim())))
136
+ return false;
137
+ // HIGH-signal tokens are matched on the RAW text: a regime token (PHI, HIPAA, 42 CFR
138
+ // Part 2, …) is meaningful wherever it appears, INCLUDING inside a Gherkin/example
139
+ // region — a spec that says "Given the PHI we store is encrypted" is still describing
140
+ // PHI, and stripExampleRegions unconditionally strips bare Given/When/Then/Scenario/
141
+ // Examples opener lines, which would otherwise remove the token before it is ever
142
+ // matched (a hard-gate fail-open). Only the LOW-signal English words (billing/claims/
143
+ // reimbursement — common outside any regulated context) are the example-block
144
+ // false-positive source stripExampleRegions exists to suppress (#64c), so that scan
145
+ // alone runs on the stripped text.
146
+ if (HIGH_SIGNAL_RE.test(text))
147
+ return true;
148
+ return countDistinctLowSignalTypes(stripExampleRegions(text)) >= 2;
51
149
  }
52
150
  /** True when a generated test source has ≥1 ACTIVE `test(` block (not `test.fixme`/
53
151
  * `test.describe`) and is not an AC-less placeholder. Pure (operates on source text). */
@@ -192,13 +290,19 @@ export function isLifecycleArtifact(filename) {
192
290
  return new RegExp(`${left}${esc}${right}`).test(upper);
193
291
  });
194
292
  }
293
+ /** Uppercase descriptor suffixes that mark a file as a derivative artifact OF a primary
294
+ * spec (a `{ID}-TASKS.md`, `{ID}-PLAN.md`, …) rather than a spec itself. Factored out so
295
+ * {@link isDerivativeArtifact} and {@link isSilentlySkippedSpecFile} agree byte-for-byte
296
+ * on which reserved suffixes are EXPECTED non-primaries. Matched against the UPPER-CASED
297
+ * basename, so no `i` flag is needed. */
298
+ const DERIVATIVE_DESCRIPTOR_RE = /-(TASKS|PLAN|CONTEXT|RESEARCH|NAVIGATION|RECOMMENDATION|RECOMMENDATIONS|DECISION|DECISIONS|SUMMARY|IMPLEMENTATION|WORKSTREAM|AUDIT|REPORT|SIGNOFF|READINESS|EXPANSION|WORKSHEET|PATTERNS)/;
195
299
  /** True when a filename + its containing dir indicate a derivative artifact, not a primary spec. */
196
300
  export function isDerivativeArtifact(dirName, filename) {
197
301
  if (NON_PRIMARY_DIRS.has(dirName))
198
302
  return true;
199
303
  if (isLifecycleArtifact(filename) || isPhaseLifecycleFile(filename))
200
304
  return true;
201
- return /-(TASKS|PLAN|CONTEXT|RESEARCH|NAVIGATION|RECOMMENDATION|RECOMMENDATIONS|DECISION|DECISIONS|SUMMARY|IMPLEMENTATION|WORKSTREAM|AUDIT|REPORT|SIGNOFF|READINESS|EXPANSION|WORKSHEET|PATTERNS)/i.test(specBasename(filename).toUpperCase());
305
+ return DERIVATIVE_DESCRIPTOR_RE.test(specBasename(filename).toUpperCase());
202
306
  }
203
307
  /** Uppercase derivative-artifact descriptors that, by repo convention, live ALONGSIDE
204
308
  * primary specs in `specs/{core}/specs|ux/` AND deliberately carry the PARENT spec's
@@ -279,6 +383,47 @@ export function resolveSpecFileId(core, filename, declaredId) {
279
383
  return id;
280
384
  return null;
281
385
  }
386
+ /**
387
+ * True when an on-disk filename PARSES to a well-formed primary-spec id yet the corpus
388
+ * loader would silently DROP it ({@link resolveSpecFileId} → null) for a reason a
389
+ * frontmatter `id:` could rescue — a reserved lifecycle word (REVIEW / ANALYSIS /
390
+ * CLARIFICATION …) landed inside a real feature slug and no `id:` disambiguates it. This
391
+ * is the silent-authoring-drop class issue #76 surfaces: the file reads as spec X to a
392
+ * human but never reaches spec-index.json / navigator / reconcile / recall.
393
+ *
394
+ * Deliberately EXCLUDES bona-fide sibling artifacts that legitimately carry a parent id
395
+ * and are SUPPOSED to be non-primary, so the surfacer stays quiet on a healthy corpus:
396
+ * - dedicated CONTEXT/CLARIFICATION/ENHANCEMENTS/EXPANSION files ({@link isDedicatedArtifactFile}),
397
+ * - `{ID}-PHASE…` lifecycle files ({@link isPhaseLifecycleFile}),
398
+ * - descriptor-suffix artifacts (`-TASKS`/`-PLAN`/`-RESEARCH`/… — {@link DERIVATIVE_DESCRIPTOR_RE}),
399
+ * - and a LONE terminal descriptor (`{ID}-REVIEW`, `{ID}-ANALYSIS`): only files with real
400
+ * slug content AFTER the id (≥2 slug words) are flagged, i.e. the reserved word is
401
+ * embedded in a feature name (`{ID}-compliance-review-workflow`), not the whole slug.
402
+ *
403
+ * The residue is exactly the case the mint-time guard (spec-id-guard → {@link resolveSpecFileId})
404
+ * blocks on `create`/`promote`, made visible for files already on disk. Pure (fs-free); the
405
+ * fs walk that feeds it lives in specs.ts `findSkippedSpecFiles`.
406
+ */
407
+ export function isSilentlySkippedSpecFile(core, filename, declaredId) {
408
+ if (!filename.endsWith('.md'))
409
+ return false;
410
+ const base = specBasename(filename);
411
+ const id = extractSpecId(filename, core);
412
+ if (!id)
413
+ return false; // basename doesn't parse to a spec id → not an intended primary spec
414
+ if (resolveSpecFileId(core, filename, declaredId) !== null)
415
+ return false; // loader already indexes it → fine
416
+ if (isDedicatedArtifactFile(filename))
417
+ return false; // CONTEXT/CLARIFICATION/ENHANCEMENTS/EXPANSION — legit sibling
418
+ if (isPhaseLifecycleFile(filename))
419
+ return false; // {ID}-PHASE… — legit lifecycle artifact
420
+ if (DERIVATIVE_DESCRIPTOR_RE.test(base.toUpperCase()))
421
+ return false; // -TASKS/-PLAN/-RESEARCH/… — legit descriptor
422
+ // Only flag when real slug content follows the id — a lone terminal descriptor
423
+ // (`{ID}-REVIEW`, `{ID}-ANALYSIS`) is an artifact, not a dropped spec.
424
+ const remainder = base.slice(id.length).replace(/^-+/, '');
425
+ return remainder.includes('-');
426
+ }
282
427
  /** The skip rules `collectSpecsFromDirectory` applies to one filename. */
283
428
  export function isPrimarySpecFile(filename) {
284
429
  if (!filename.endsWith('.md'))