@popoverai/dotrequirements 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/codebase-to-spec/compose.js +0 -4
  2. package/dist/codebase-to-spec/renumber.d.ts +7 -5
  3. package/dist/codebase-to-spec/renumber.js +17 -15
  4. package/dist/commands/report.js +15 -1
  5. package/dist/commands/sync.js +7 -1
  6. package/dist/harness/cache.d.ts +83 -2
  7. package/dist/harness/cache.js +94 -8
  8. package/dist/harness/finalize.js +238 -78
  9. package/dist/harness/reportingStatus.d.ts +44 -0
  10. package/dist/harness/reportingStatus.js +123 -0
  11. package/dist/push/core.d.ts +0 -12
  12. package/dist/push/core.js +8 -58
  13. package/dist/push/index.d.ts +1 -1
  14. package/dist/push/index.js +1 -1
  15. package/dist/requirements/cloud-coverage.js +7 -2
  16. package/dist/schema/browser.d.ts +2 -2
  17. package/dist/schema/browser.js +2 -2
  18. package/dist/schema/builder.d.ts +6 -1
  19. package/dist/schema/builder.js +6 -1
  20. package/dist/schema/conversions.js +12 -3
  21. package/dist/schema/file-writer.d.ts +49 -0
  22. package/dist/schema/file-writer.js +138 -0
  23. package/dist/schema/index.d.ts +5 -2
  24. package/dist/schema/index.js +3 -2
  25. package/dist/schema/parser-core.d.ts +32 -5
  26. package/dist/schema/parser-core.js +136 -31
  27. package/dist/schema/parser.d.ts +2 -1
  28. package/dist/schema/parser.js +1 -1
  29. package/dist/schema/schemas.d.ts +11 -0
  30. package/dist/schema/schemas.js +14 -0
  31. package/dist/sync/compare.js +16 -1
  32. package/dist/sync/execute.d.ts +10 -2
  33. package/dist/sync/execute.js +44 -18
  34. package/dist/sync/local-files.d.ts +2 -12
  35. package/dist/sync/local-files.js +2 -62
  36. package/dist/sync/segment.d.ts +2 -2
  37. package/dist/sync/segment.js +45 -11
  38. package/package.json +2 -2
  39. package/dist/harness/convexReporting.d.ts +0 -15
  40. package/dist/harness/convexReporting.js +0 -131
  41. package/dist/harness/coverageCache.d.ts +0 -30
  42. package/dist/harness/coverageCache.js +0 -70
@@ -82,10 +82,6 @@ export function assembleComposedDocument(outline, partialPathFor) {
82
82
  partialsMissing,
83
83
  };
84
84
  }
85
- /** Escape a string for safe YAML scalar use. */
86
- function escapeYamlString(s) {
87
- return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
88
- }
89
85
  /**
90
86
  * Compose the spec and write it to disk. Renumbers requirement IDs into a
91
87
  * clean sequence (CTS-COMPOSE-3), then validates the result.
@@ -34,11 +34,13 @@ export interface Rename {
34
34
  * a contiguous 1..N sequence. Returns the rename list (including identity
35
35
  * renames for already-clean IDs, so callers can treat the result uniformly).
36
36
  *
37
- * Only the first line of each ```dotrequirements block is treated as a header
38
- * (one requirement per block, matching the parser's contract). Exact-duplicate
39
- * keys are a distinct validation error and out of scope here: a repeated key
40
- * does not consume a sequence slot (so it introduces no phantom gap) and keeps
41
- * its first mapping, leaving the downstream validator to flag the duplicate.
37
+ * A fence may hold several adjacent requirements (SYNC-FORMAT-4), so every
38
+ * header line in a block counts, not just the first. HEADER_RE is anchored at
39
+ * column zero and criteria are indented, so criterion text never reads as a
40
+ * header. Exact-duplicate keys are a distinct validation error and out of scope
41
+ * here: a repeated key does not consume a sequence slot (so it introduces no
42
+ * phantom gap) and keeps its first mapping, leaving the downstream validator to
43
+ * flag the duplicate.
42
44
  */
43
45
  export declare function computeRenames(content: string): Rename[];
44
46
  /**
@@ -41,11 +41,13 @@ function escapeRegExp(s) {
41
41
  * a contiguous 1..N sequence. Returns the rename list (including identity
42
42
  * renames for already-clean IDs, so callers can treat the result uniformly).
43
43
  *
44
- * Only the first line of each ```dotrequirements block is treated as a header
45
- * (one requirement per block, matching the parser's contract). Exact-duplicate
46
- * keys are a distinct validation error and out of scope here: a repeated key
47
- * does not consume a sequence slot (so it introduces no phantom gap) and keeps
48
- * its first mapping, leaving the downstream validator to flag the duplicate.
44
+ * A fence may hold several adjacent requirements (SYNC-FORMAT-4), so every
45
+ * header line in a block counts, not just the first. HEADER_RE is anchored at
46
+ * column zero and criteria are indented, so criterion text never reads as a
47
+ * header. Exact-duplicate keys are a distinct validation error and out of scope
48
+ * here: a repeated key does not consume a sequence slot (so it introduces no
49
+ * phantom gap) and keeps its first mapping, leaving the downstream validator to
50
+ * flag the duplicate.
49
51
  */
50
52
  export function computeRenames(content) {
51
53
  const counters = new Map();
@@ -54,18 +56,18 @@ export function computeRenames(content) {
54
56
  BLOCK_RE.lastIndex = 0;
55
57
  let block = BLOCK_RE.exec(content);
56
58
  while (block !== null) {
57
- const body = block[1];
58
- const firstLine = body.split("\n").find((l) => l.trim().length > 0) ?? "";
59
- const m = firstLine.match(HEADER_RE);
60
- if (m) {
59
+ for (const line of block[1].split("\n")) {
60
+ const m = line.match(HEADER_RE);
61
+ if (!m)
62
+ continue;
61
63
  const [, prefix, num, suffix] = m;
62
64
  const from = `${prefix}-${num}${suffix}`;
63
- if (!seen.has(from)) {
64
- seen.add(from);
65
- const next = (counters.get(prefix) ?? 0) + 1;
66
- counters.set(prefix, next);
67
- renames.push({ from, to: `${prefix}-${next}` });
68
- }
65
+ if (seen.has(from))
66
+ continue;
67
+ seen.add(from);
68
+ const next = (counters.get(prefix) ?? 0) + 1;
69
+ counters.set(prefix, next);
70
+ renames.push({ from, to: `${prefix}-${next}` });
69
71
  }
70
72
  block = BLOCK_RE.exec(content);
71
73
  }
@@ -1,3 +1,5 @@
1
+ import { findRequirementsDir, readCoverageCache } from "../harness/cache.js";
2
+ import { formatUnrecordedNotice } from "../harness/reportingStatus.js";
1
3
  import { getProjectCoverage, getRequirementCoverage, } from "../requirements/cloud-coverage.js";
2
4
  import { buildLocalReport, } from "../requirements/coverage.js";
3
5
  import { findProjectRoot, getProjectCredentials, } from "../utils/project-settings.js";
@@ -5,11 +7,23 @@ const CONVEX_URL = "https://data.dotrequirements.io";
5
7
  export async function reportCommand(options) {
6
8
  const source = options.source ?? "local";
7
9
  const format = options.format ?? "console";
10
+ const projectRoot = findProjectRoot(process.cwd());
11
+ // COVERAGE-REPORT-3.1: someone asking about coverage learns first that
12
+ // coverage stopped recording. On stderr so machine-readable formats stay
13
+ // parseable.
14
+ if (projectRoot) {
15
+ const requirementsDir = findRequirementsDir(projectRoot);
16
+ const notice = requirementsDir
17
+ ? formatUnrecordedNotice(readCoverageCache(requirementsDir))
18
+ : null;
19
+ if (notice) {
20
+ console.error(notice);
21
+ }
22
+ }
8
23
  if (source === "local" && (options.branch || options.since !== undefined)) {
9
24
  throw new Error("--branch and --since require --source cloud. Local reports cover only the most recent test run on this machine.");
10
25
  }
11
26
  if (source === "local") {
12
- const projectRoot = findProjectRoot(process.cwd());
13
27
  if (!projectRoot) {
14
28
  throw new Error('Could not find .requirements/ directory. Run "dotrequirements init" to initialize your project.');
15
29
  }
@@ -93,7 +93,7 @@ export async function syncCommand(scope, options) {
93
93
  return "clean";
94
94
  }
95
95
  console.log(`\nSyncing with ${brand} cloud...`);
96
- const outcome = await executePlan(plan, cloud, auth, workspaceRoot);
96
+ const outcome = await executePlan(plan, cloud, auth, workspaceRoot, mode);
97
97
  // Report.
98
98
  if (outcome.cloudCreated)
99
99
  console.log(` Created in cloud: ${outcome.cloudCreated}`);
@@ -119,6 +119,12 @@ export async function syncCommand(scope, options) {
119
119
  for (const r of outcome.renames) {
120
120
  console.log(` renamed "${r.from}" → "${r.to}"`);
121
121
  }
122
+ // DOC-HEADER-11.4: a prefix nobody wrote down was read off the first
123
+ // requirement key — name it rather than letting it appear in the frontmatter
124
+ // unannounced.
125
+ for (const { name, prefix } of outcome.inferredPrefixes) {
126
+ console.log(` ${name}: no prefix in frontmatter — using "${prefix}"`);
127
+ }
122
128
  // IMPORT-3: marker failures are loud but never fatal.
123
129
  for (const { name, status } of outcome.importWarnings) {
124
130
  if (status === "already_used") {
@@ -64,9 +64,55 @@ export interface CoverageCacheEntry {
64
64
  /** Legacy field retained for backward-compat reads of pre-attribution caches. */
65
65
  requirementKeys?: string[];
66
66
  }
67
+ /**
68
+ * The most recent reporting attempt that did not reach the cloud.
69
+ *
70
+ * Written on every failed attempt and cleared the moment one succeeds, so its
71
+ * presence alone answers "is coverage recording right now?" (COVERAGE-REPORT-3).
72
+ */
73
+ /** One requirement's coverage as it is sent to the cloud. */
74
+ export interface CoverageRow {
75
+ requirementKey: string;
76
+ testFile?: string;
77
+ testLine?: number;
78
+ user?: string;
79
+ }
80
+ /**
81
+ * A report that did not land, kept verbatim so a later run can replay it.
82
+ *
83
+ * Stored whole rather than as bare tuples for two reasons. A later run only
84
+ * knows the requirements *it* exercised, so anything narrower can never retry
85
+ * what a partial run (`vitest run one.test.ts`, a CI shard) doesn't touch. And
86
+ * branch and attribution belong to the original run — replaying them under a
87
+ * later run's branch would file the coverage against the wrong one.
88
+ */
89
+ export interface PendingCoverageReport {
90
+ branch?: string;
91
+ context?: string;
92
+ runId?: string;
93
+ coverage: CoverageRow[];
94
+ }
95
+ export interface CoverageReportFailure {
96
+ timestamp: number;
97
+ /** The reason as it will be shown to the developer. */
98
+ reason: string;
99
+ /** The report to replay. Absent on caches written before replay existed. */
100
+ pending?: PendingCoverageReport;
101
+ }
67
102
  export interface CoverageCache {
68
103
  current: CoverageCacheEntry | null;
69
104
  previous: CoverageCacheEntry | null;
105
+ /**
106
+ * When a report last actually reached the cloud.
107
+ *
108
+ * Deliberately separate from `current.timestamp`, which the debounce path
109
+ * also refreshes without contacting anything — anchoring "coverage last
110
+ * recorded …" on that would let a daily-driver suite roll the claim forward
111
+ * indefinitely while the cloud went stale (COVERAGE-REPORT-3.0).
112
+ */
113
+ lastRecordedAt?: number;
114
+ /** Absent while reporting is healthy. */
115
+ lastFailure?: CoverageReportFailure;
70
116
  }
71
117
  /**
72
118
  * Find the nearest .requirements directory by walking up from startDir
@@ -128,10 +174,45 @@ export declare function readTrackingEntries(requirementsDir: string): TrackingEn
128
174
  export declare function deleteTrackingFile(requirementsDir: string): void;
129
175
  export declare function readCoverageCache(requirementsDir: string): CoverageCache;
130
176
  /**
131
- * Update the coverage cache after reporting. Accepts either attribution tuples
132
- * (new) or bare requirement keys (legacy; converted to tuples with no context/branch).
177
+ * Update the debounce window after a reporting attempt. Accepts either
178
+ * attribution tuples (new) or bare requirement keys (legacy; converted to
179
+ * tuples with no context/branch).
180
+ *
181
+ * Says nothing about whether anything reached the cloud — the debounce path
182
+ * calls this having contacted nothing. Whether coverage is recording is
183
+ * `lastRecordedAt` / `lastFailure`, and both are carried through untouched.
133
184
  */
134
185
  export declare function updateCoverageCache(requirementsDir: string, testRunId: string, tuples: CoverageTuple[] | string[]): void;
186
+ /**
187
+ * Record that coverage actually reached the cloud: refresh the debounce window,
188
+ * stamp when it landed, and drop any standing failure (COVERAGE-REPORT-3.3).
189
+ *
190
+ * This is the only path that may clear `lastFailure` on the strength of a
191
+ * successful report.
192
+ */
193
+ export declare function recordCoverageReported(requirementsDir: string, testRunId: string, tuples: CoverageTuple[] | string[], timestamp?: number): void;
194
+ /**
195
+ * Record that a reporting attempt did not reach the cloud, preserving when
196
+ * coverage last actually recorded — that is what "coverage last recorded …" is
197
+ * measured from (COVERAGE-REPORT-3.0).
198
+ */
199
+ export declare function recordCoverageReportFailure(requirementsDir: string, reason: string, timestamp?: number, pending?: PendingCoverageReport): void;
200
+ /**
201
+ * Drop a standing failure without claiming a report landed.
202
+ *
203
+ * For when reporting has stopped being applicable at all — an unlinked project
204
+ * is not failing to record, so the notice must not outlive the link
205
+ * (COVERAGE-REPORT-3.3).
206
+ */
207
+ /**
208
+ * Record that a previously failed report has now landed on replay.
209
+ *
210
+ * Stamps when coverage recorded and drops the failure, leaving the debounce
211
+ * window alone — the replay carries an earlier run's tuples, which say nothing
212
+ * about what this run should send (COVERAGE-REPORT-3.4).
213
+ */
214
+ export declare function recordCoverageReplayed(requirementsDir: string, timestamp?: number): void;
215
+ export declare function clearCoverageReportFailure(requirementsDir: string): void;
135
216
  export declare const COVERAGE_DEBOUNCE_WINDOW_MS: number;
136
217
  /**
137
218
  * Check if an attribution tuple needs reporting based on the coverage cache.
@@ -286,6 +286,26 @@ function normalizeEntry(raw) {
286
286
  }
287
287
  return null;
288
288
  }
289
+ /**
290
+ * Normalize the last-failure record read from disk. Caches written before
291
+ * COVERAGE-REPORT-3 have no such field, which reads as "reporting is healthy".
292
+ */
293
+ function normalizeFailure(raw) {
294
+ if (!raw || typeof raw !== "object")
295
+ return undefined;
296
+ const failure = raw;
297
+ if (typeof failure.timestamp !== "number")
298
+ return undefined;
299
+ if (typeof failure.reason !== "string")
300
+ return undefined;
301
+ const pending = failure.pending;
302
+ const replayable = pending && typeof pending === "object" && Array.isArray(pending.coverage);
303
+ return {
304
+ timestamp: failure.timestamp,
305
+ reason: failure.reason,
306
+ ...(replayable ? { pending } : {}),
307
+ };
308
+ }
289
309
  export function readCoverageCache(requirementsDir) {
290
310
  const cacheDir = getCacheDir(requirementsDir);
291
311
  const coveragePath = path.join(cacheDir, COVERAGE_FILE);
@@ -295,9 +315,13 @@ export function readCoverageCache(requirementsDir) {
295
315
  try {
296
316
  const content = fs.readFileSync(coveragePath, "utf-8");
297
317
  const raw = JSON.parse(content);
318
+ const lastFailure = normalizeFailure(raw?.lastFailure);
319
+ const lastRecordedAt = typeof raw?.lastRecordedAt === "number" ? raw.lastRecordedAt : undefined;
298
320
  return {
299
321
  current: normalizeEntry(raw?.current),
300
322
  previous: normalizeEntry(raw?.previous),
323
+ ...(lastRecordedAt !== undefined ? { lastRecordedAt } : {}),
324
+ ...(lastFailure ? { lastFailure } : {}),
301
325
  };
302
326
  }
303
327
  catch {
@@ -305,25 +329,87 @@ export function readCoverageCache(requirementsDir) {
305
329
  }
306
330
  }
307
331
  /**
308
- * Update the coverage cache after reporting. Accepts either attribution tuples
309
- * (new) or bare requirement keys (legacy; converted to tuples with no context/branch).
332
+ * Update the debounce window after a reporting attempt. Accepts either
333
+ * attribution tuples (new) or bare requirement keys (legacy; converted to
334
+ * tuples with no context/branch).
335
+ *
336
+ * Says nothing about whether anything reached the cloud — the debounce path
337
+ * calls this having contacted nothing. Whether coverage is recording is
338
+ * `lastRecordedAt` / `lastFailure`, and both are carried through untouched.
310
339
  */
311
340
  export function updateCoverageCache(requirementsDir, testRunId, tuples) {
312
- const cacheDir = getCacheDir(requirementsDir, true);
313
- const coveragePath = path.join(cacheDir, COVERAGE_FILE);
314
341
  const normalized = tuples.map((t) => typeof t === "string" ? { requirementKey: t } : t);
315
342
  const existing = readCoverageCache(requirementsDir);
316
- const newCache = {
343
+ writeCoverageCache(requirementsDir, {
344
+ ...existing,
317
345
  current: {
318
346
  testRunId,
319
347
  timestamp: Date.now(),
320
348
  tuples: normalized,
321
349
  },
322
350
  previous: existing.current,
323
- };
324
- fs.writeFileSync(coveragePath, JSON.stringify(newCache, null, 2));
351
+ });
352
+ }
353
+ /**
354
+ * Record that coverage actually reached the cloud: refresh the debounce window,
355
+ * stamp when it landed, and drop any standing failure (COVERAGE-REPORT-3.3).
356
+ *
357
+ * This is the only path that may clear `lastFailure` on the strength of a
358
+ * successful report.
359
+ */
360
+ export function recordCoverageReported(requirementsDir, testRunId, tuples, timestamp = Date.now()) {
361
+ updateCoverageCache(requirementsDir, testRunId, tuples);
362
+ const existing = readCoverageCache(requirementsDir);
363
+ writeCoverageCache(requirementsDir, {
364
+ current: existing.current,
365
+ previous: existing.previous,
366
+ lastRecordedAt: timestamp,
367
+ });
368
+ }
369
+ /**
370
+ * Record that a reporting attempt did not reach the cloud, preserving when
371
+ * coverage last actually recorded — that is what "coverage last recorded …" is
372
+ * measured from (COVERAGE-REPORT-3.0).
373
+ */
374
+ export function recordCoverageReportFailure(requirementsDir, reason, timestamp = Date.now(), pending) {
375
+ const existing = readCoverageCache(requirementsDir);
376
+ writeCoverageCache(requirementsDir, {
377
+ ...existing,
378
+ lastFailure: { timestamp, reason, ...(pending ? { pending } : {}) },
379
+ });
380
+ }
381
+ /**
382
+ * Drop a standing failure without claiming a report landed.
383
+ *
384
+ * For when reporting has stopped being applicable at all — an unlinked project
385
+ * is not failing to record, so the notice must not outlive the link
386
+ * (COVERAGE-REPORT-3.3).
387
+ */
388
+ /**
389
+ * Record that a previously failed report has now landed on replay.
390
+ *
391
+ * Stamps when coverage recorded and drops the failure, leaving the debounce
392
+ * window alone — the replay carries an earlier run's tuples, which say nothing
393
+ * about what this run should send (COVERAGE-REPORT-3.4).
394
+ */
395
+ export function recordCoverageReplayed(requirementsDir, timestamp = Date.now()) {
396
+ const existing = readCoverageCache(requirementsDir);
397
+ const { lastFailure: _cleared, ...rest } = existing;
398
+ writeCoverageCache(requirementsDir, { ...rest, lastRecordedAt: timestamp });
399
+ }
400
+ export function clearCoverageReportFailure(requirementsDir) {
401
+ const existing = readCoverageCache(requirementsDir);
402
+ if (!existing.lastFailure)
403
+ return;
404
+ const { lastFailure: _dropped, ...rest } = existing;
405
+ writeCoverageCache(requirementsDir, rest);
406
+ }
407
+ function writeCoverageCache(requirementsDir, cache) {
408
+ const cacheDir = getCacheDir(requirementsDir, true);
409
+ const coveragePath = path.join(cacheDir, COVERAGE_FILE);
410
+ fs.writeFileSync(coveragePath, JSON.stringify(cache, null, 2));
325
411
  }
326
- export const COVERAGE_DEBOUNCE_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours — must match server-side in testCoverage/mutations.ts
412
+ export const COVERAGE_DEBOUNCE_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours — must match DEBOUNCE_WINDOW_MS in testCoverage/helpers.ts
327
413
  function tuplesMatch(a, b) {
328
414
  return (a.requirementKey === b.requirementKey &&
329
415
  a.context === b.context &&