@am_shork/attest 0.7.2 → 0.7.4

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.
@@ -13,9 +13,9 @@ import { DEFAULT_TARGET, resolveTargets } from './targets.js';
13
13
  import { writeAtomic } from './write.js';
14
14
  import { applyMerge, mergedSpecPath } from './merge.js';
15
15
  import { compilerIssue } from './compiler.js';
16
- import { mkdir, readFile } from 'node:fs/promises';
16
+ import { mkdir, readFile, realpath } from 'node:fs/promises';
17
17
  import { basename, dirname, join } from 'node:path';
18
- import { relativePath } from './paths.js';
18
+ import { isInside, relativePath } from './paths.js';
19
19
  import { hasError } from './types.js';
20
20
  // The runner half of the engine, reached only when a command actually needs it.
21
21
  //
@@ -108,7 +108,7 @@ export async function runCheck(root, options = {}) {
108
108
  // command that reads both the registry and every change's delta, so it is the
109
109
  // only one where that distinction is worth anything.
110
110
  return withLoader(options, async (loader) => {
111
- const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
111
+ const { registry, issues, unreadable } = await readRegistry(root, options, scan.reqsFiles, loader);
112
112
  const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
113
113
  return [
114
114
  ...issues,
@@ -121,7 +121,7 @@ export async function runCheck(root, options = {}) {
121
121
  // that *did* load are all still true. What it must not do is advise work
122
122
  // that the load failure makes wrong — see `orphan-test` in
123
123
  // `validateStructure`.
124
- ...validateStructure(registry, plan, unreadableFiles.length > 0),
124
+ ...validateStructure(registry, plan, unreadable),
125
125
  ...detectPotentialDrift(registry, plan, plan.paramRefs),
126
126
  ...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
127
127
  ...(await changeDirSpecIssues(root)),
@@ -291,13 +291,13 @@ export async function runVerify(root, options = {}) {
291
291
  const loader = await createLoader();
292
292
  let registry;
293
293
  let plan;
294
- let unreadableFiles;
294
+ let unreadable;
295
295
  const issues = [];
296
296
  try {
297
297
  const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
298
298
  registry = loaded.registry;
299
299
  issues.push(...loaded.issues);
300
- unreadableFiles = loaded.unreadableFiles;
300
+ unreadable = loaded.unreadable;
301
301
  const parsedSpecs = await parseSpecs(scan.specFiles, root);
302
302
  plan = parsedSpecs.plan;
303
303
  issues.push(...parsedSpecs.issues);
@@ -307,7 +307,7 @@ export async function runVerify(root, options = {}) {
307
307
  }
308
308
  // Same as `check`: everything the loaded half supports is still reported, and
309
309
  // only the advice that a load failure would make wrong is withdrawn.
310
- issues.push(...validateStructure(registry, plan, unreadableFiles.length > 0));
310
+ issues.push(...validateStructure(registry, plan, unreadable));
311
311
  issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
312
312
  const attesting = attestingFiles(plan);
313
313
  const counts = {
@@ -450,15 +450,78 @@ export async function runInit(root, names) {
450
450
  const { targets, issues } = resolveTargets(names.length > 0 ? names : [DEFAULT_TARGET]);
451
451
  if (issues.length > 0)
452
452
  return { files: [], issues };
453
- const files = [];
453
+ // Every destination is resolved before any of them is written, for the reason
454
+ // `resolveTargets` refuses the whole set on one unknown name: a run that stops
455
+ // partway leaves a repository carrying instructions for some agents and a
456
+ // failing command that does not say which.
457
+ // A root that does not exist yet is `init <dir>` on a directory this run
458
+ // creates, and nothing can have been planted inside a directory that is not
459
+ // there — so it stands for itself and every segment below it resolves
460
+ // lexically, which is what the old implementation did for all of them.
461
+ const realRoot = await realpath(root).catch(() => root);
462
+ const writes = [];
463
+ const refusals = [];
454
464
  for (const target of targets) {
455
- const dest = join(root, target.file);
465
+ const resolved = await resolveDest(realRoot, target.file);
466
+ if (resolved.ok) {
467
+ writes.push({ target, dest: resolved.dest });
468
+ }
469
+ else {
470
+ refusals.push({
471
+ level: 'ERROR',
472
+ code: 'unsafe-target-path',
473
+ file: target.file,
474
+ message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
475
+ });
476
+ }
477
+ }
478
+ if (refusals.length > 0)
479
+ return { files: [], issues: refusals };
480
+ const files = [];
481
+ for (const { target, dest } of writes) {
456
482
  await mkdir(dirname(dest), { recursive: true });
457
483
  await writeAtomic(dest, target.content());
458
484
  files.push(target.file);
459
485
  }
460
486
  return { files, issues };
461
487
  }
488
+ /**
489
+ * Where a target's file really goes, or the path outside the project that
490
+ * asking put it at (design §9: ownership of the path is checked, not assumed).
491
+ *
492
+ * `join(root, file)` answers where the path *reads* as going, and that is not
493
+ * the same question: a segment that is a link makes a path lexically inside the
494
+ * root and physically outside it, which `mkdir -p` and the write both follow.
495
+ * So the resolution is the check, and a lexical one would not be — `join`
496
+ * normalises `..`, so it is already satisfied here. `writeAtomic`'s `wx` is not
497
+ * the defence either and was never scoped to be: it guards the *temporary* path
498
+ * against a planted link, in whichever directory the destination turns out to
499
+ * be.
500
+ *
501
+ * So every segment is resolved, not just the leaf. A link anywhere along the
502
+ * way is the escape, and the leaf is checked too — `rename` replaces a symlink
503
+ * rather than following it, so a link there would not leak the content, but it
504
+ * would silently destroy a file the user made deliberately.
505
+ *
506
+ * A segment that does not exist cannot be a link, and neither can the ones
507
+ * below it, so `realpath` failing means the rest of the path is Attest's own
508
+ * `mkdir` to create. What this cannot close is a link planted between this
509
+ * resolution and the write; the exposure it does close is one committed to the
510
+ * repository, which is the one a checkout hands you.
511
+ */
512
+ async function resolveDest(realRoot, file) {
513
+ let current = realRoot;
514
+ // `AgentTarget.file` is spelled with `/` on every platform (ATX-28), so the
515
+ // segments are the same list here as they are in the report.
516
+ for (const segment of file.split('/')) {
517
+ const next = join(current, segment);
518
+ const real = await realpath(next).catch(() => next);
519
+ if (!isInside(realRoot, real))
520
+ return { ok: false, escape: relativePath(realRoot, real) };
521
+ current = real;
522
+ }
523
+ return { ok: true, dest: current };
524
+ }
462
525
  /**
463
526
  * A change name must name one directory inside `changes/`, and nothing else.
464
527
  *
@@ -6,7 +6,7 @@
6
6
  // back, so the direction OpenSpec's pure-Markdown model went (Markdown as
7
7
  // truth, and the free-form drift that comes with it) stays closed.
8
8
  //
9
- // Four properties this file must keep:
9
+ // Five properties this file must keep:
10
10
  // - **Intent only.** The document says what the system promises, never what is
11
11
  // proven or green: coverage and results are verdicts, and verdicts belong to
12
12
  // `cover` / `verify`, which recompute them on demand. Putting them here also
@@ -25,6 +25,10 @@
25
25
  // and it is a *file* — committed, served, and read again long after the run
26
26
  // that wrote it. See `sanitised` for why the defence sits here rather than
27
27
  // at the terminal write.
28
+ // - **Prose stays prose.** A string the registry owns may be marked up and may
29
+ // not become a tag or a heading: the document promises one section per
30
+ // requirement, and a rationale that opens its own breaks the only structural
31
+ // claim the document makes. See `prose` (design §9.1).
28
32
  import { byCodeUnit, sortDeep } from './order.js';
29
33
  import { control } from './terminal.js';
30
34
  /** Whether a value reads as words in a sentence — a scalar, or a list of them. */
@@ -199,7 +203,7 @@ function compareIds(a, b) {
199
203
  function overviewTable(registry, ids) {
200
204
  const rows = ids.map((id) => {
201
205
  const req = registry[id];
202
- return `| [${id}](${anchor(id)}) | ${cell(interpolate(req.statement, req.params))} |`;
206
+ return `| [${id}](${anchor(id)}) | ${cell(statementText(req))} |`;
203
207
  });
204
208
  return ['| ID | Requirement |', '| --- | --- |', ...rows];
205
209
  }
@@ -207,9 +211,9 @@ function section(id, req) {
207
211
  const out = [
208
212
  `## ${id}`,
209
213
  '',
210
- interpolate(req.statement, req.params),
214
+ statementText(req),
211
215
  '',
212
- `**Why:** ${req.rationale}`,
216
+ `**Why:** ${prose(req.rationale)}`,
213
217
  ];
214
218
  const params = Object.entries(req.params);
215
219
  if (params.length > 0) {
@@ -225,33 +229,200 @@ function section(id, req) {
225
229
  }
226
230
  }
227
231
  if (req.outOfScope.length > 0) {
228
- out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
232
+ out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
229
233
  }
230
234
  return out;
231
235
  }
236
+ /** The statement as the document says it, with its params in place. */
237
+ function statementText(req) {
238
+ return prose(req.statement, req.params);
239
+ }
240
+ /**
241
+ * Author prose as Markdown that cannot become HTML and cannot open a section,
242
+ * with `{param}` placeholders substituted as it goes (design §9.1).
243
+ *
244
+ * One pass rather than two because the two jobs need the same fact — which
245
+ * regime each character is in. Escaping first and substituting after would run
246
+ * the value through a scanner reading backticks as structure; substituting
247
+ * first and escaping after would let a value's own backtick open a code span
248
+ * that was never in the statement, and carry the rest of the sentence out of
249
+ * the defence. Knowing the position is also what lets a value be escaped
250
+ * *for* it, which is the difference between `emphasised` and `literal` below.
251
+ *
252
+ * Which of the three things a registry string could do here is intended, and
253
+ * why this escapes rather than refuses, is §9.1. What is local to this function
254
+ * is where it stops: **a code span is left exactly as written**, because a
255
+ * span's contents are literal text in every renderer and an entity written
256
+ * there would be visible rather than decoded. The match is therefore
257
+ * deliberately conservative — a backtick run opens a span only if a run of
258
+ * *exactly* that length closes it (CommonMark), and an unmatched run is text
259
+ * with the defence still applied after it. Being wrong the other way, reading
260
+ * text as a span the renderer will not, is the only direction that leaks.
261
+ */
262
+ function prose(text, params = {}) {
263
+ const spans = spanReader(text);
264
+ const out = [];
265
+ let i = 0;
266
+ let atLineStart = true;
267
+ while (i < text.length) {
268
+ const ch = text[i];
269
+ if (ch === '`') {
270
+ const end = spans.spanEndAt(i);
271
+ out.push(substitute(text.slice(i, end), params, literal));
272
+ atLineStart = false;
273
+ i = end;
274
+ continue;
275
+ }
276
+ if (ch === '{') {
277
+ const placeholder = /^\{\w+\}/.exec(text.slice(i));
278
+ if (placeholder !== null) {
279
+ out.push(substitute(placeholder[0], params, emphasised));
280
+ atLineStart = false;
281
+ i += placeholder[0].length;
282
+ continue;
283
+ }
284
+ }
285
+ if (ch === '\n') {
286
+ out.push(ch);
287
+ atLineStart = true;
288
+ i += 1;
289
+ continue;
290
+ }
291
+ if (atLineStart) {
292
+ // Leading whitespace does not end the line's opening position: ` # x` is
293
+ // a heading, and four spaces make an indented code block where the escape
294
+ // is visible but harmless. Counting the indent to tell those apart would
295
+ // be a second Markdown reader for one cosmetic case.
296
+ if (ch === ' ' || ch === '\t') {
297
+ out.push(ch);
298
+ i += 1;
299
+ continue;
300
+ }
301
+ atLineStart = false;
302
+ // A heading opens a section; a line of `=` or `-` alone makes a heading
303
+ // out of the line *above* it, which is the same forgery approaching from
304
+ // behind. A backslash before either renders the character and nothing else.
305
+ if (ch === '#' || SETEXT.test(lineAt(text, i))) {
306
+ out.push('\\');
307
+ continue;
308
+ }
309
+ }
310
+ out.push(ch === '<' ? '&lt;' : ch);
311
+ i += 1;
312
+ }
313
+ return out.join('');
314
+ }
315
+ /** A line that would turn the one above it into a heading. */
316
+ const SETEXT = /^(?:=+|-+)[ \t]*$/;
317
+ function lineAt(text, from) {
318
+ const end = text.indexOf('\n', from);
319
+ return end === -1 ? text.slice(from) : text.slice(from, end);
320
+ }
321
+ /**
322
+ * Where each code span in `text` ends, answered in constant time per question.
323
+ *
324
+ * The obvious implementation searches forward from the opening run for a run of
325
+ * the same length, and it is the ATX-59 defect in a second costume: a run that
326
+ * closes nothing scans to the end of the string, so prose whose runs are all of
327
+ * *different* lengths pays that for every one of them. Measured, before this:
328
+ * 500 KB of such a rationale took 1.6 s and 2 MB took 15 s — from `attest
329
+ * render` with no flag, on text the registry chooses.
330
+ *
331
+ * So the runs are read once and indexed by length, and each length keeps a
332
+ * cursor into its own list. Openers are visited left to right, so a cursor only
333
+ * ever moves forward and the total work is bounded by the number of runs. A
334
+ * shorter or longer run is not a closer and is never a candidate here, which is
335
+ * the CommonMark rule and also what stops a scanner believing in a span the
336
+ * renderer does not.
337
+ */
338
+ function spanReader(text) {
339
+ const runs = [];
340
+ for (let i = 0; i < text.length; i += 1) {
341
+ if (text[i] !== '`')
342
+ continue;
343
+ const start = i;
344
+ while (text[i] === '`')
345
+ i += 1;
346
+ runs.push({ at: start, length: i - start });
347
+ i -= 1;
348
+ }
349
+ const byLength = new Map();
350
+ runs.forEach((run, index) => {
351
+ const list = byLength.get(run.length);
352
+ if (list === undefined)
353
+ byLength.set(run.length, [index]);
354
+ else
355
+ list.push(index);
356
+ });
357
+ const cursors = new Map();
358
+ let nextRun = 0;
359
+ return {
360
+ /** The end of the span opening at `at`, or the end of that run if none. */
361
+ spanEndAt: (at) => {
362
+ while (runs[nextRun].at < at)
363
+ nextRun += 1;
364
+ const opener = runs[nextRun];
365
+ const candidates = byLength.get(opener.length);
366
+ let cursor = cursors.get(opener.length) ?? 0;
367
+ while (cursor < candidates.length && candidates[cursor] <= nextRun)
368
+ cursor += 1;
369
+ cursors.set(opener.length, cursor);
370
+ const closer = cursor < candidates.length ? runs[candidates[cursor]] : undefined;
371
+ return closer === undefined ? at + opener.length : closer.at + closer.length;
372
+ },
373
+ };
374
+ }
232
375
  /**
233
- * Substitute `{param}` placeholders with the actual value, emphasised so a
234
- * reader can see which words in the sentence are pinned to a single source.
376
+ * Substitute `{param}` placeholders in one region with `render`, the spelling
377
+ * that region's position calls for.
378
+ *
235
379
  * An unbound placeholder is left verbatim — `check` already reports it as an
236
380
  * `unbound-param` error, and silently swallowing it here would hide that.
237
381
  */
238
- function interpolate(statement, params) {
382
+ function substitute(region, params, render) {
239
383
  // `Object.hasOwn`, never `in`: `'toString' in {}` is true, and an `in` probe
240
384
  // would splice `function toString() { [native code] }` into a document that
241
385
  // reviewers and audit read as the system's promise.
242
- return statement.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? `**${plain(params[name])}**` : whole);
386
+ return region.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? render(params[name]) : whole);
387
+ }
388
+ /**
389
+ * A param value in prose position: escaped, and emphasised so a reader can see
390
+ * which words in the sentence are pinned to a single source.
391
+ *
392
+ * Escaped unlike the statement around it, because those are prose an author may
393
+ * want to mark up and a value is data that must survive verbatim — an unescaped
394
+ * `*` would otherwise break out of the emphasis it is wrapped in. `<` and `#`
395
+ * are in the class for the same reason the rest of it is: they are structure
396
+ * *here*. A value is spliced into a sentence, so a tag in one is a tag in the
397
+ * document, and a value carrying a newline puts what follows at the start of a
398
+ * line, where `#` opens a section (design §9.1).
399
+ */
400
+ function emphasised(value) {
401
+ const one = (v) => v.replace(/([*_`[\]\\#])/g, '\\$1').replaceAll('<', '&lt;');
402
+ return `**${listed(value, (v) => one(inlineText(v)))}**`;
243
403
  }
244
404
  /**
245
- * A param value as prose, for inline use inside a statement. Escaped, unlike
246
- * the statement and rationale around it: those are prose an author may want to
247
- * mark up, a param value is data and must survive verbatim an unescaped `*`
248
- * would otherwise break out of the emphasis it is wrapped in.
405
+ * A param value inside the author's code span: the value, and nothing added.
406
+ *
407
+ * A code span's contents are literal in every renderer, so escaping there is
408
+ * not merely unnecessary, it is *visible* Markdown does not decode `&lt;`
409
+ * inside one, and this repository's own `ATX-30` renders a path with `<name>`
410
+ * in it. Emphasis is dropped for the same reason: `**` in a code span is two
411
+ * asterisks, which is what the document said before this knew where it was.
412
+ *
413
+ * The exception is a value that would end the span early — a backtick, or the
414
+ * blank line a code span cannot contain. There the author has asked for two
415
+ * incompatible things, and the prose escape is the safe answer rather than the
416
+ * pretty one: what follows the broken span is author text this pass has already
417
+ * walked past.
249
418
  */
250
- function plain(value) {
251
- const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
252
- return Array.isArray(value)
253
- ? value.map((v) => one(inlineText(v))).join(', ')
254
- : one(inlineText(value));
419
+ function literal(value) {
420
+ const text = listed(value, inlineText);
421
+ return text.includes('`') || /\n[ \t]*\n/.test(text) ? emphasised(value) : text;
422
+ }
423
+ /** A list param reads as its members; a scalar reads as itself. */
424
+ function listed(value, one) {
425
+ return Array.isArray(value) ? value.map(one).join(', ') : one(value);
255
426
  }
256
427
  /**
257
428
  * One param value as a run of text.
@@ -273,9 +444,7 @@ function inlineText(value) {
273
444
  function formatValue(value) {
274
445
  if (!isFlat(value))
275
446
  return '_see below_';
276
- return Array.isArray(value)
277
- ? value.map((v) => code(inlineText(v))).join(', ')
278
- : code(inlineText(value));
447
+ return listed(value, (v) => code(inlineText(v)));
279
448
  }
280
449
  /**
281
450
  * A structured param as a fenced JSON block.
@@ -93,6 +93,15 @@ promises is a two-stage workflow, and the stages are separate on purpose.
93
93
  through any edit. Pin the expectation to something that does not move with the
94
94
  param — a fixture, a literal in the test, or a second independently derived
95
95
  value.
96
+ - **A param the scenario loops over is the *domain* of the run, and shortening it
97
+ is silent.** Drop a member and you drop a case; every assertion inside the loop
98
+ still passes over what is left, with the expectation as independent as it ever
99
+ was — so the rule above does not reach this. Pin the extent beside the loop:
100
+ the members against a literal when their identity is the promise, the count
101
+ when the size is. That literal is **not** the hardcoded value stage 2's third
102
+ rule forbids, and the distinction is the whole of it — that rule is about the
103
+ **expectation** the system is measured against; this pin asserts what the
104
+ **intent** is.
96
105
 
97
106
  ## Rules the engine enforces
98
107
 
@@ -313,6 +322,16 @@ failure this framework exists to make visible:
313
322
  is the drift the single source exists to prevent, and the param is typed at
314
323
  the value written in the registry, so a stale expectation stops compiling
315
324
  rather than silently passing.
325
+ *This forbids one literal and not the other, and the difference decides
326
+ whether a domain can be shortened.* Forbidden is a literal standing in for the
327
+ param **as the value the system is measured against** — that is the second
328
+ copy. Required, when a scenario **iterates** a list it read from \`params\`, is
329
+ a literal pinning that list or its length beside the loop: the list is then
330
+ the set of cases the run covers rather than a value under test, and dropping a
331
+ member removes a case while every assertion inside still passes. A literal is
332
+ the only independent term available when what is at risk is the **size** of
333
+ the set, so the two never collide — one asserts what the system does, the
334
+ other what the intent promised.
316
335
  *One thing to know before implementation code reads a param this change
317
336
  ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
318
337
  the delta in memory — so that read throws at import and the gate answers
@@ -1,7 +1,7 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
2
  import type { Registry } from './types.js';
3
3
  /** Codes this reader can produce; each is also produced by the evaluating path. */
4
- export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static';
4
+ export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static' | 'unreadable-file';
5
5
  export type StaticReadResult = {
6
6
  ok: true;
7
7
  registry: Registry;
@@ -13,7 +13,7 @@ export type StaticReadResult = {
13
13
  };
14
14
  export declare function readRegistrySource(file: string, source: string): StaticReadResult;
15
15
  /** Codes the delta reader can produce; both are codes `archive` already emits. */
16
- export type DeltaReadCode = 'change-not-found' | 'registry-not-static';
16
+ export type DeltaReadCode = 'change-not-found' | 'registry-not-static' | 'unreadable-file';
17
17
  export type DeltaReadResult = {
18
18
  ok: true;
19
19
  delta: RegistryDelta;
@@ -39,6 +39,38 @@ export type DeltaReadResult = {
39
39
  * (write the value inline, or `--eval`) is the same sentence either way.
40
40
  */
41
41
  export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
42
+ /**
43
+ * The requirement ids a source *declares*, read from a file that could not be
44
+ * read as a registry (design §5.3).
45
+ *
46
+ * This is not a second registry reader and cannot become one: it returns ids and
47
+ * nothing else, it validates none of them, and no command builds a `Registry`
48
+ * from what it finds. What it exists for is the question `orphan-test` cannot
49
+ * otherwise answer — is this scenario attesting an id that a *broken* file
50
+ * declares, or one that genuinely does not exist — and the answer decides
51
+ * whether a finding is a fact or fallout.
52
+ *
53
+ * It reads the ids rather than the file's id *prefix*, which is what this was
54
+ * first framed as needing. The prefix cannot be recovered from a path: this
55
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
56
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
57
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
58
+ * inferred from a naming convention that nothing enforces.
59
+ *
60
+ * Recall is partial by construction, and the direction of the miss is the point:
61
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
62
+ * never wrong. Measured over the failure modes that produce an unreadable
63
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
64
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
65
+ * another module all yield the ids written in this file; only a registry built
66
+ * by a call — `export default buildReqs()` — yields none, and that file contains
67
+ * no id to find.
68
+ *
69
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
70
+ * first of those cases work at all: the parser recovers an object literal from a
71
+ * file that does not compile.
72
+ */
73
+ export declare function declaredIdsFromSource(file: string, source: string): string[];
42
74
  /**
43
75
  * Where a new entry may be written into a registry file's literal, as an offset
44
76
  * into its source (design §7).