@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.
- package/dist/commands/close-out.js +48 -2
- package/dist/commands/close-out.js.map +1 -1
- package/dist/commands/doctor.js +14 -5
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/index-spec.js +22 -4
- package/dist/commands/index-spec.js.map +1 -1
- package/dist/commands/next.d.ts +6 -0
- package/dist/commands/next.js +11 -1
- package/dist/commands/next.js.map +1 -1
- package/dist/commands/reconcile.js +6 -2
- package/dist/commands/reconcile.js.map +1 -1
- package/dist/commands/scorecard.js +51 -0
- package/dist/commands/scorecard.js.map +1 -1
- package/dist/commands/signoff.js +12 -1
- package/dist/commands/signoff.js.map +1 -1
- package/dist/commands/status-drift.d.ts +2 -0
- package/dist/commands/status-drift.js +35 -0
- package/dist/commands/status-drift.js.map +1 -0
- package/dist/commands/verify-acs.d.ts +25 -1
- package/dist/commands/verify-acs.js +118 -36
- package/dist/commands/verify-acs.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/body-requirements.d.ts +30 -0
- package/dist/lib/body-requirements.js +56 -0
- package/dist/lib/body-requirements.js.map +1 -0
- package/dist/lib/closeout-core.d.ts +15 -0
- package/dist/lib/closeout-core.js +42 -5
- package/dist/lib/closeout-core.js.map +1 -1
- package/dist/lib/config.d.ts +24 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/doctor-core.d.ts +17 -2
- package/dist/lib/doctor-core.js +50 -12
- package/dist/lib/doctor-core.js.map +1 -1
- package/dist/lib/frontmatter.d.ts +15 -0
- package/dist/lib/frontmatter.js +87 -3
- package/dist/lib/frontmatter.js.map +1 -1
- package/dist/lib/reconcile-core.d.ts +1 -1
- package/dist/lib/reconcile-core.js +10 -2
- package/dist/lib/reconcile-core.js.map +1 -1
- package/dist/lib/rtm-scorecard-run.d.ts +38 -0
- package/dist/lib/rtm-scorecard-run.js +70 -0
- package/dist/lib/rtm-scorecard-run.js.map +1 -0
- package/dist/lib/rtm-scorecard.d.ts +146 -0
- package/dist/lib/rtm-scorecard.js +194 -0
- package/dist/lib/rtm-scorecard.js.map +1 -0
- package/dist/lib/spec-pure.d.ts +38 -0
- package/dist/lib/spec-pure.js +162 -17
- package/dist/lib/spec-pure.js.map +1 -1
- package/dist/lib/specs.d.ts +20 -0
- package/dist/lib/specs.js +37 -1
- package/dist/lib/specs.js.map +1 -1
- package/dist/lib/status-drift-run.d.ts +9 -0
- package/dist/lib/status-drift-run.js +73 -0
- package/dist/lib/status-drift-run.js.map +1 -0
- package/dist/lib/status-drift.d.ts +43 -0
- package/dist/lib/status-drift.js +79 -0
- package/dist/lib/status-drift.js.map +1 -0
- package/dist/lib/verifies-backlinks.d.ts +13 -0
- package/dist/lib/verifies-backlinks.js +33 -0
- package/dist/lib/verifies-backlinks.js.map +1 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/pure.d.ts +2 -0
- package/dist/pure.js +6 -0
- package/dist/pure.js.map +1 -1
- 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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"}
|
package/dist/lib/spec-pure.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/spec-pure.js
CHANGED
|
@@ -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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
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'))
|