@am_shork/attest 0.4.2 → 0.5.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/README.md CHANGED
@@ -8,18 +8,26 @@ by a stable ID and continuously detects drift.
8
8
  - **Result** — is every test green? (from the test runner)
9
9
  - **Drift** — do intent and assertions still agree? (static + runtime cross-check)
10
10
 
11
- The killer move against drift: values that change (timeouts, limits) live **once**
12
- in a requirement's `params`, and tests read them from there — so a number is
13
- physically impossible to drift between the spec and the assertion. A param may be
14
- a scalar or an array of scalars, so list-shaped constants (vendor blacklists, id
15
- sets) get the same single source as a lone number.
11
+ The killer move against drift: values a requirement **promises** (timeouts,
12
+ limits, budgets) live **once** in its `params`, and tests read them from there —
13
+ so a number is physically impossible to drift between the spec and the assertion.
14
+ Values that merely tune behaviour stay ordinary constants; nothing is owed to
15
+ anyone when a tuning knob changes. A param may be a scalar or an array of
16
+ scalars, so list-shaped constants (vendor blacklists, id sets) get the same
17
+ single source as a lone number.
16
18
 
17
19
  What that does not buy is a warning when you change the value. `check` runs
18
20
  nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
19
21
  nothing became uncovered. The value cannot *diverge* from the assertion, which is
20
22
  the stronger property; noticing that it *moved* is `verify`'s job, and only when
21
- a scenario asserts on the value it read from `params`. Read the param inside the
22
- assertion, not beside it.
23
+ a scenario asserts on the value it read from `params`.
24
+
25
+ Reading the param is necessary and **not sufficient**, which is worth knowing
26
+ before you rely on it: an assertion that recomputes its expectation from the same
27
+ param the code under test just read has no independent term, so both sides move
28
+ together and the test stays green through any edit. Pin the expectation to
29
+ something that does not move with the param — a fixture, a literal in the test,
30
+ or a second independently derived value.
23
31
 
24
32
  ## Prerequisites
25
33
 
@@ -67,13 +75,13 @@ export default defineRequirements({
67
75
  });
68
76
  ```
69
77
 
70
- **2. Attest it with a scenario** (`auth/session.spec.ts`):
78
+ **2. Attest it with scenarios** (`session.spec.ts`):
71
79
 
72
80
  ```ts
73
81
  import { expect } from 'vitest';
74
82
  import { requirement, scenario } from '@am_shork/attest';
75
- import reqs from '../requirements/auth.reqs.js';
76
- import { createSession, advance, isValid } from './session.js';
83
+ import reqs from './requirements/auth.reqs.js';
84
+ import { createSession, advance, touch, isValid } from './session.js';
77
85
 
78
86
  requirement('AUTH-3', () => {
79
87
  scenario('idle timeout invalidates the session', () => {
@@ -82,9 +90,22 @@ requirement('AUTH-3', () => {
82
90
  advance(s, t + 1, 'minutes');
83
91
  expect(isValid(s, t)).toBe(false);
84
92
  });
93
+
94
+ scenario('activity resets the idle timer', () => {
95
+ const t = reqs['AUTH-3'].params.idleTimeoutMin;
96
+ const s = createSession();
97
+ advance(s, t - 1, 'minutes');
98
+ touch(s);
99
+ advance(s, t - 1, 'minutes');
100
+ expect(isValid(s, t)).toBe(true);
101
+ });
85
102
  });
86
103
  ```
87
104
 
105
+ Both files above are quoted from `fixtures/consumer/`, which the packaging test
106
+ installs from a real tarball and runs — a test asserts the quotes are byte-equal
107
+ to the files, so a sample the engine would now reject cannot survive here.
108
+
88
109
  **3. Run the engine:**
89
110
 
90
111
  ```bash
@@ -136,7 +157,7 @@ Every diagnostic carries a `code`, and every code has a section in
136
157
  ```
137
158
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
138
159
  Value is not a literal.
139
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.4.2/docs/en/troubleshooting.md#registry-not-static
160
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.5.0/docs/en/troubleshooting.md#registry-not-static
140
161
  ```
141
162
 
142
163
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -12,6 +12,21 @@ export interface ApplyResult {
12
12
  * than off `delta.added`, so the two can never be scoped differently.
13
13
  */
14
14
  export declare function addedIds(d: RegistryDelta): string[];
15
+ /**
16
+ * The ids a delta **claims**: what it adds, renames to, or modifies.
17
+ *
18
+ * This is how a proposed spec is attributed to a change (design §7). A spec
19
+ * sitting at its merged location carries no change name, so the delta names its
20
+ * specs the only way that cannot drift from them — by the requirements they
21
+ * declare a scenario for. Wider than `addedIds` on purpose: a delta that
22
+ * retunes a param or renames an id may need a scenario of its own, and one that
23
+ * could not be claimed would be a spec the gate never ran.
24
+ *
25
+ * REMOVED ids are absent. A scenario for a requirement the change deletes is
26
+ * not proposed behaviour, and claiming it would put a spec into the gate run
27
+ * whose requirement the same delta has just taken away.
28
+ */
29
+ export declare function claimedIds(d: RegistryDelta): string[];
15
30
  /**
16
31
  * Apply a change delta to a base registry, returning the merged registry plus
17
32
  * any apply-level issues (conflicts, missing targets, invalid results). The
@@ -14,6 +14,27 @@ import { byCodeUnit } from './order.js';
14
14
  export function addedIds(d) {
15
15
  return Object.keys(d.added ?? {});
16
16
  }
17
+ /**
18
+ * The ids a delta **claims**: what it adds, renames to, or modifies.
19
+ *
20
+ * This is how a proposed spec is attributed to a change (design §7). A spec
21
+ * sitting at its merged location carries no change name, so the delta names its
22
+ * specs the only way that cannot drift from them — by the requirements they
23
+ * declare a scenario for. Wider than `addedIds` on purpose: a delta that
24
+ * retunes a param or renames an id may need a scenario of its own, and one that
25
+ * could not be claimed would be a spec the gate never ran.
26
+ *
27
+ * REMOVED ids are absent. A scenario for a requirement the change deletes is
28
+ * not proposed behaviour, and claiming it would put a spec into the gate run
29
+ * whose requirement the same delta has just taken away.
30
+ */
31
+ export function claimedIds(d) {
32
+ return [
33
+ ...Object.keys(d.added ?? {}),
34
+ ...(d.renamed ?? []).map((r) => r.to),
35
+ ...Object.keys(d.modified ?? {}),
36
+ ].sort(byCodeUnit);
37
+ }
17
38
  /**
18
39
  * Apply a change delta to a base registry, returning the merged registry plus
19
40
  * any apply-level issues (conflicts, missing targets, invalid results). The
@@ -132,8 +153,29 @@ function introducedIdIssue(id, code, prefix) {
132
153
  function err(code, message, reqId) {
133
154
  return { level: 'ERROR', code, ...(reqId ? { reqId } : {}), message };
134
155
  }
156
+ /**
157
+ * The first schema failure, as `path: message`.
158
+ *
159
+ * The path is the half that was being dropped. A requirement carries a record —
160
+ * `params` — so "invalid" on its own does not say *which* param, and the value
161
+ * that most often fails is one the author has to find by reading the schema's
162
+ * `.d.ts`. `RegistryValidationError` has spelled a path `a.b.c` since the
163
+ * registry shipped, and `troubleshooting.md` has been quoting `rationale:
164
+ * Required` for `add-invalid` the whole time — a form this function could not
165
+ * produce. So this is the spelling matching what is already documented, not a
166
+ * new one.
167
+ *
168
+ * Omitted rather than rendered as `(root)` when the path is empty, because the
169
+ * one caller that gets an empty path is `introducedIdIssue`, whose prefix has
170
+ * already named the thing: `Added requirement "auth-7" is invalid: id must look
171
+ * like AUTH-3` reads correctly and `(root): id must look like AUTH-3` does not.
172
+ */
135
173
  function firstMessage(error) {
136
- return error.issues[0]?.message ?? 'unknown error';
174
+ const first = error.issues[0];
175
+ if (!first)
176
+ return 'unknown error';
177
+ const path = first.path.map(String).join('.');
178
+ return path ? `${path}: ${first.message}` : first.message;
137
179
  }
138
180
  /** Content equality via canonical JSON (params key order does not matter). */
139
181
  function sameRequirement(a, b) {
@@ -8,7 +8,7 @@
8
8
  * and the `##` headings of both language documents, so landing here cannot
9
9
  * produce a dead link.
10
10
  */
11
- export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
11
+ export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
12
12
  export type IssueCode = (typeof ISSUE_CODES)[number];
13
13
  /**
14
14
  * The page explaining `code`, or `undefined` when nothing explains it.
package/dist/core/docs.js CHANGED
@@ -21,6 +21,7 @@ import { packageVersion } from './version.js';
21
21
  export const ISSUE_CODES = [
22
22
  'add-conflict',
23
23
  'add-invalid',
24
+ 'added-id-unmerged',
24
25
  'change-not-found',
25
26
  'declared-not-run',
26
27
  'duplicate-prefix',
@@ -34,6 +35,7 @@ export const ISSUE_CODES = [
34
35
  'never-red',
35
36
  'orphan-test',
36
37
  'possible-drift',
38
+ 'proposed-spec-unclaimed',
37
39
  'rationale-placeholder',
38
40
  'registry-invalid',
39
41
  'registry-no-default',
@@ -9,6 +9,15 @@ export interface GateInputs {
9
9
  run: RunResult;
10
10
  /** Ids this change ADDs — the scenarios that carry a first-red obligation. */
11
11
  addedIds?: readonly string[];
12
+ /**
13
+ * Of those, the ids the registry **on disk** does not have yet.
14
+ *
15
+ * Computed against the base registry rather than the applied one, because
16
+ * that is the registry the child run imports: the gate applies the delta in
17
+ * memory, and the suite is a separate process reading `*.reqs.ts` off the
18
+ * filesystem. The two disagreeing is the whole content of `added-id-unmerged`.
19
+ */
20
+ unmergedAddedIds?: readonly string[];
12
21
  /** First observed outcome per scenario, already merged with this run. */
13
22
  firstRun?: RedRecord;
14
23
  }
@@ -21,6 +30,32 @@ export interface GateInputs {
21
30
  * this framework is that agreement should be structural, not clerical.
22
31
  */
23
32
  export declare function declaredNotRunIssues(plan: AttestPlan, run: RunResult): Issue[];
33
+ /**
34
+ * A spec file that failed to load, while the change adds ids the registry on
35
+ * disk does not have yet (design §8).
36
+ *
37
+ * The gate applies the delta in memory and hands the result to itself; the
38
+ * suite is a child process that imports `*.reqs.ts` from the filesystem. So
39
+ * implementation code doing exactly what the workflow requires —
40
+ * `reqs['FOG-4'].params.modestRisk` for an id the change *adds* — throws at
41
+ * import, every spec file transitively importing that module fails to load, and
42
+ * what the gate could see was `tests-red` plus a `declared-not-run` per
43
+ * scenario, whose message sends the reader to look for a `skip` or an `.only`
44
+ * that is not there. The change could not be made green by the documented
45
+ * workflow, and the verdict pointed away from the reason.
46
+ *
47
+ * This names the reason instead. It is a diagnosis, not a fix: the run still
48
+ * fails, and the way forward is still to merge the added requirement into the
49
+ * registry before running the gate. What it buys is that the reader is told
50
+ * that, rather than sent to audit their own spec files for a skip.
51
+ *
52
+ * The conjunction is the whole test, and it is deliberately a heuristic: a
53
+ * module can fail to import for reasons that have nothing to do with a
54
+ * requirement id. Naming an unmerged id when one exists is more useful than
55
+ * silence, so the message states the two facts and the inference between them
56
+ * rather than asserting a cause.
57
+ */
58
+ export declare function unmergedIdIssues(run: RunResult, unmergedAddedIds: readonly string[]): Issue[];
24
59
  /**
25
60
  * Never-red: a scenario attesting a requirement this change ADDs, whose first
26
61
  * observed run did not fail (design §6, mechanism 2).
@@ -48,10 +83,12 @@ export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly stri
48
83
  * the registry+plan here are already the applied result, so one pass
49
84
  * validates the end state.
50
85
  * 2. Executable: all tests green.
51
- * 3. Static coverage vs runtime coverage: every declared scenario actually
86
+ * 3. A spec file that failed to load while the change adds an id the registry
87
+ * on disk lacks — the reason, named before the absences it causes.
88
+ * 4. Static coverage vs runtime coverage: every declared scenario actually
52
89
  * ran (catches skip/only false coverage).
53
- * 4. Never-red: every scenario of an ADDED requirement failed on its first
90
+ * 5. Never-red: every scenario of an ADDED requirement failed on its first
54
91
  * recorded run (design §6, mechanism 2).
55
92
  */
56
- export declare function evaluateGate({ registry, plan, run, addedIds, firstRun }: GateInputs): Issue[];
93
+ export declare function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds, firstRun, }: GateInputs): Issue[];
57
94
  //# sourceMappingURL=gate.d.ts.map
package/dist/core/gate.js CHANGED
@@ -29,6 +29,43 @@ export function declaredNotRunIssues(plan, run) {
29
29
  }
30
30
  return issues;
31
31
  }
32
+ /**
33
+ * A spec file that failed to load, while the change adds ids the registry on
34
+ * disk does not have yet (design §8).
35
+ *
36
+ * The gate applies the delta in memory and hands the result to itself; the
37
+ * suite is a child process that imports `*.reqs.ts` from the filesystem. So
38
+ * implementation code doing exactly what the workflow requires —
39
+ * `reqs['FOG-4'].params.modestRisk` for an id the change *adds* — throws at
40
+ * import, every spec file transitively importing that module fails to load, and
41
+ * what the gate could see was `tests-red` plus a `declared-not-run` per
42
+ * scenario, whose message sends the reader to look for a `skip` or an `.only`
43
+ * that is not there. The change could not be made green by the documented
44
+ * workflow, and the verdict pointed away from the reason.
45
+ *
46
+ * This names the reason instead. It is a diagnosis, not a fix: the run still
47
+ * fails, and the way forward is still to merge the added requirement into the
48
+ * registry before running the gate. What it buys is that the reader is told
49
+ * that, rather than sent to audit their own spec files for a skip.
50
+ *
51
+ * The conjunction is the whole test, and it is deliberately a heuristic: a
52
+ * module can fail to import for reasons that have nothing to do with a
53
+ * requirement id. Naming an unmerged id when one exists is more useful than
54
+ * silence, so the message states the two facts and the inference between them
55
+ * rather than asserting a cause.
56
+ */
57
+ export function unmergedIdIssues(run, unmergedAddedIds) {
58
+ if (unmergedAddedIds.length === 0)
59
+ return [];
60
+ return run.unloadedFiles.map((file) => ({
61
+ level: 'ERROR',
62
+ code: 'added-id-unmerged',
63
+ file,
64
+ message: `${file} failed to load, and this change adds ${unmergedAddedIds.join(', ')}, which the registry on disk does not have yet. ` +
65
+ `The suite runs against the registry files, not the applied registry the gate computed, so code reading a requirement this change adds throws at import. ` +
66
+ `Merge the added requirement into the registry and run the gate again: applying a delta whose ADDED entry already exists with identical content is a no-op, so the change still documents the intent.`,
67
+ }));
68
+ }
32
69
  /**
33
70
  * Never-red: a scenario attesting a requirement this change ADDs, whose first
34
71
  * observed run did not fail (design §6, mechanism 2).
@@ -77,12 +114,14 @@ export function neverRedIssues(plan, addedIds, firstRun) {
77
114
  * the registry+plan here are already the applied result, so one pass
78
115
  * validates the end state.
79
116
  * 2. Executable: all tests green.
80
- * 3. Static coverage vs runtime coverage: every declared scenario actually
117
+ * 3. A spec file that failed to load while the change adds an id the registry
118
+ * on disk lacks — the reason, named before the absences it causes.
119
+ * 4. Static coverage vs runtime coverage: every declared scenario actually
81
120
  * ran (catches skip/only false coverage).
82
- * 4. Never-red: every scenario of an ADDED requirement failed on its first
121
+ * 5. Never-red: every scenario of an ADDED requirement failed on its first
83
122
  * recorded run (design §6, mechanism 2).
84
123
  */
85
- export function evaluateGate({ registry, plan, run, addedIds, firstRun }) {
124
+ export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds, firstRun, }) {
86
125
  const blocking = [];
87
126
  // 1) Structure (end-state re-validation).
88
127
  blocking.push(...validateStructure(registry, plan).filter((i) => i.level === 'ERROR'));
@@ -94,9 +133,20 @@ export function evaluateGate({ registry, plan, run, addedIds, firstRun }) {
94
133
  message: 'Some tests are failing; the change cannot be archived.',
95
134
  });
96
135
  }
97
- // 3) Declared-not-run: static coverage claimed, runtime never executed it.
98
- blocking.push(...declaredNotRunIssues(plan, run));
99
- // 4) Never-red: the added scenarios have to have discriminated once.
136
+ // 3) A file that failed to load, named before the absences it produces.
137
+ const unmerged = unmergedIdIssues(run, unmergedAddedIds ?? []);
138
+ blocking.push(...unmerged);
139
+ // 4) Declared-not-run: static coverage claimed, runtime never executed it.
140
+ //
141
+ // Scenarios in a file diagnosed just above are left out. Not because they
142
+ // ran — they could not have — but because "declared but never executed
143
+ // (skipped, or excluded by an .only?)" is one fact restated as a guess about
144
+ // a cause the line above has already established. Only when that line was
145
+ // emitted: with no diagnosis to replace it, a wrong message still beats
146
+ // silence, which is why `verify` keeps reporting them.
147
+ const diagnosed = new Set(unmerged.map((i) => i.file));
148
+ blocking.push(...declaredNotRunIssues(plan, run).filter((i) => !diagnosed.has(i.file)));
149
+ // 5) Never-red: the added scenarios have to have discriminated once.
100
150
  if (addedIds && addedIds.length > 0) {
101
151
  blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
102
152
  }
@@ -1,6 +1,24 @@
1
1
  import type { Loader } from './loader.js';
2
2
  import type { AttestPlan, Issue, Registry } from './types.js';
3
3
  export declare const isReqsFile: (name: string) => boolean;
4
+ /**
5
+ * A spec belonging to a change that has not been agreed yet (design §7).
6
+ *
7
+ * The marker is in the **name** rather than the directory because the file
8
+ * already sits where it will live once the change is merged: a proposal's spec
9
+ * is written next to the code it attests, so its relative imports resolve
10
+ * identically before and after the merge, and merging renames it in place
11
+ * instead of moving it up a tree and rewriting every specifier.
12
+ */
13
+ export declare const isProposedSpecFile: (name: string) => boolean;
14
+ /**
15
+ * A spec belonging to the merged suite.
16
+ *
17
+ * Proposed specs are excluded here rather than at each call site, so the base
18
+ * scan, the base run and every reporting command are wrong together or not at
19
+ * all: a red scenario for behaviour nobody has implemented must not reach any
20
+ * of them until its gate passes.
21
+ */
4
22
  export declare const isSpecFile: (name: string) => boolean;
5
23
  /**
6
24
  * Recursively find files under root whose basename matches `match`.
@@ -11,16 +29,21 @@ export declare const isSpecFile: (name: string) => boolean;
11
29
  * order never depends on which `readdir` happened to resolve first.
12
30
  */
13
31
  export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
14
- /** The two file sets every command needs, collected in one pass. */
32
+ /** The file sets every command needs, collected in one pass. */
15
33
  export interface ProjectScan {
16
34
  reqsFiles: string[];
17
35
  specFiles: string[];
36
+ /** Specs of changes still under review; never part of the base suite. */
37
+ proposedSpecFiles: string[];
18
38
  }
19
39
  /**
20
40
  * Find the registry and spec files under root in a **single** traversal.
21
41
  *
22
42
  * Calling findFiles once per pattern meant `check` walked the tree twice and
23
- * `archive` four times, over a tree that cannot change in between.
43
+ * `archive` four times, over a tree that cannot change in between. Proposed
44
+ * specs are collected in the same pass for that reason and kept in their own
45
+ * list: they are found everywhere the merged ones are, and no caller may reach
46
+ * them by accident.
24
47
  */
25
48
  export declare function scanProject(root: string): Promise<ProjectScan>;
26
49
  /** One file's worth of registry, or the single issue that stopped it. */
@@ -92,8 +115,6 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
92
115
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<AttestPlan>;
93
116
  /** Parse every `*.spec.ts` under root into one merged plan (file paths shown relative to root). */
94
117
  export declare function parseAllSpecFiles(root: string): Promise<AttestPlan>;
95
- /** Parse the spec files belonging to a single proposed change (design §8). */
96
- export declare function parseChangeSpecs(root: string, changeName: string): Promise<AttestPlan>;
97
118
  /** List the names of proposed changes under `root/changes`. */
98
119
  export declare function listChangeNames(root: string): Promise<string[]>;
99
120
  //# sourceMappingURL=locate.d.ts.map
@@ -20,7 +20,25 @@ const SKIP_DIRS = new Set([
20
20
  'archive',
21
21
  ]);
22
22
  export const isReqsFile = (name) => name.endsWith('.reqs.ts');
23
- export const isSpecFile = (name) => name.endsWith('.spec.ts');
23
+ /**
24
+ * A spec belonging to a change that has not been agreed yet (design §7).
25
+ *
26
+ * The marker is in the **name** rather than the directory because the file
27
+ * already sits where it will live once the change is merged: a proposal's spec
28
+ * is written next to the code it attests, so its relative imports resolve
29
+ * identically before and after the merge, and merging renames it in place
30
+ * instead of moving it up a tree and rewriting every specifier.
31
+ */
32
+ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
33
+ /**
34
+ * A spec belonging to the merged suite.
35
+ *
36
+ * Proposed specs are excluded here rather than at each call site, so the base
37
+ * scan, the base run and every reporting command are wrong together or not at
38
+ * all: a red scenario for behaviour nobody has implemented must not reach any
39
+ * of them until its gate passes.
40
+ */
41
+ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
24
42
  /**
25
43
  * Recursively find files under root whose basename matches `match`.
26
44
  *
@@ -53,19 +71,29 @@ export async function findFiles(root, match) {
53
71
  * Find the registry and spec files under root in a **single** traversal.
54
72
  *
55
73
  * Calling findFiles once per pattern meant `check` walked the tree twice and
56
- * `archive` four times, over a tree that cannot change in between.
74
+ * `archive` four times, over a tree that cannot change in between. Proposed
75
+ * specs are collected in the same pass for that reason and kept in their own
76
+ * list: they are found everywhere the merged ones are, and no caller may reach
77
+ * them by accident.
57
78
  */
58
79
  export async function scanProject(root) {
59
- const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n));
80
+ const files = await findFiles(root, (n) => isReqsFile(n) || isSpecFile(n) || isProposedSpecFile(n));
60
81
  const reqsFiles = [];
61
82
  const specFiles = [];
83
+ const proposedSpecFiles = [];
62
84
  for (const f of files) {
63
- if (isReqsFile(basename(f)))
85
+ const name = basename(f);
86
+ // Sorted by `isSpecFile` rather than by `isProposedSpecFile`, so what makes
87
+ // a spec part of the merged suite is decided in exactly one place: the two
88
+ // predicates would otherwise both have to be right about the same file.
89
+ if (isReqsFile(name))
64
90
  reqsFiles.push(f);
65
- else
91
+ else if (isSpecFile(name))
66
92
  specFiles.push(f);
93
+ else
94
+ proposedSpecFiles.push(f);
67
95
  }
68
- return { reqsFiles, specFiles };
96
+ return { reqsFiles, specFiles, proposedSpecFiles };
69
97
  }
70
98
  /**
71
99
  * Read registries by **executing** the module through the Vite loader.
@@ -251,11 +279,6 @@ export async function parseSpecs(files, displayRoot) {
251
279
  export async function parseAllSpecFiles(root) {
252
280
  return parseSpecs(await findFiles(root, isSpecFile), root);
253
281
  }
254
- /** Parse the spec files belonging to a single proposed change (design §8). */
255
- export async function parseChangeSpecs(root, changeName) {
256
- const dir = join(root, 'changes', changeName);
257
- return parseSpecs(await findFiles(dir, isSpecFile), root);
258
- }
259
282
  /** List the names of proposed changes under `root/changes`. */
260
283
  export async function listChangeNames(root) {
261
284
  try {
Binary file
@@ -120,15 +120,6 @@ export declare function runInit(root: string, names: readonly string[]): Promise
120
120
  files: string[];
121
121
  issues: Issue[];
122
122
  }>;
123
- /**
124
- * The globs that keep *other* proposals out of a change's gate run.
125
- *
126
- * Sibling names come from `readdir`, not from the guard above, so they are
127
- * escaped: a directory called `feat(auth)` pasted in raw is a *pattern*, it
128
- * matches nothing, and that sibling's specs silently join the run — quietly
129
- * widening the scope of the one check that decides "done".
130
- */
131
- export declare function changeExcludeGlobs(others: string[]): string[];
132
123
  export interface StatusResult {
133
124
  change: string;
134
125
  /** One row per added id, in id order. */