@starci/hfs 4.0.5 → 4.0.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.6 - 2026-10-01
4
+
5
+ - Changed: R112 integration specs, the runtime api layer, the examples-root CI scope and the new pins.
6
+
3
7
  ## 4.0.5 - 2026-10-01
4
8
 
5
9
  - Changed: workflow worktree runtime copies, the services coverage scope for Sonar and codecov.yml, sonar-gate, canon-pins.
package/README.md CHANGED
@@ -63,6 +63,7 @@ Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rende
63
63
  | `HFS_REPO_LOCAL_CHECK` | error | a `check-*` file in `scripts/` or `tools/`, an `eslint-local-rules*` file or local eslint plugin, or a script that runs a local check (R103) |
64
64
  | `HFS_LINT_SUPPRESSION_FILE` | error | an `eslint.suppressions*` file, a `lint:suppressions` script, an eslint suppress flag or a suppressions config (R104) |
65
65
  | `HFS_PROOF_COMMAND_FILE_MISSING` | error | a `.starciwork` `requiresProof.<kind>.command` that runs a file the repository does not hold (R105) |
66
+ | `BE_INTEGRATION_SPEC_MISSING` | error | an integration (`src/modules/integrations/<provider>/` with `<provider>.config.ts`) without a `src/tests/integration/<provider>/*.integration-spec.ts` that registers its module through `useTestWorld({ modules })`, references its ErrorCode enum and drives an outage through the world (R112) |
66
67
  | `HFS_PEER_INTEGRATION_MISSING` | error | the app root `package.json` depends on a driver integration (a pair of `knowledge/hfs/peer-integrations.yaml`) without its runtime peer, e.g. `@nestjs/apollo` on `@nestjs/platform-express` 11 without `@as-integrations/express5` (R111) |
67
68
  | `FE_WIRE_GENERATED` | error | a contract copy with no `codegen` script wired before `build` and `typecheck`, or generated types older than the copy (R52) |
68
69
  | `FE_I18N_PLACEMENT` | error | no `next-intl`, no `src/proxy.ts`, a `middleware.ts`, a route file outside `[locale]`, no `vi.json` catalog (R59) |
@@ -121,7 +122,7 @@ properties file is not its render or the stack declaration names another gate. R
121
122
 
122
123
  Coverage is the services' alone, from one scope. The managed `test` script is `jest --selectProjects unit --coverage`: it fails below the per-file 100 threshold on `src/**/*.service.ts` and writes `be/coverage/lcov.info` (the jest preset's lcov reporter). The managed `sonar-project.properties` imports that report (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) with `sonar.coverage.inclusions=be/src/**/*.service.ts` and no other coverage key, so a handler, resolver, controller, module, config file or test is not a coverage target and `fe/` is outside coverage. The managed `codecov.yml` holds the same paths at 100 on the project and the patch and ignores `fe/**`; the managed CI workflow uploads the lcov with `codecov/codecov-action` after the unit run. `hfs sync` renders the scope into both files from the installed jest preset's `COVERAGE_SOURCES` (`coverageScope` in `sync/index.mjs`), so they can never drift. The runtime judges the coverage per file: `sonar-local.mjs scan` holds every service a slice touched at 100, and `sonar-local.mjs dashboard` fails a project unless every service is at 100.
123
124
 
124
- The upload needs the repository secret `CODECOV_TOKEN` (the owner adds it once per repository: Codecov, the repository's settings, then GitHub Settings > Secrets and variables > Actions > New repository secret `CODECOV_TOKEN`). Without it the step is skipped like the Sonar steps without `SONAR_TOKEN`.
125
+ The upload needs the repository secret `CODECOV_TOKEN` (each product monorepo gets its own; the runtime repository's root `.github/workflows/examples.yml` uses the runtime repository's one for the example apps; the owner adds it once per repository: Codecov, the repository's settings, then GitHub Settings > Secrets and variables > Actions > New repository secret `CODECOV_TOKEN`). Without it the step is skipped like the Sonar steps without `SONAR_TOKEN`.
125
126
 
126
127
  ## Maintaining the bundle
127
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.0.5",
3
+ "version": "4.0.6",
4
4
  "description": "The HFS command line of a StarCi app (one repository: the root, be/ and fe/): hfs lint, check, scaffold app, explain, sync and work-hygiene. Self-contained: it carries the runtime files it reads.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -35,7 +35,7 @@ import path from 'node:path';
35
35
  import crypto from 'node:crypto';
36
36
  import { createRequire } from 'node:module';
37
37
  import { pathToFileURL, fileURLToPath } from 'node:url';
38
- import { runGit } from '../scripts/lib/git.mjs';
38
+ import { runGit } from '../scripts/api/git/lib.mjs';
39
39
  import { sleepSync as scaledSleepSync } from '../scripts/lib/sleep-sync.mjs';
40
40
  import { putBlob as storeBlob, blobPath, artifactRoot, getBlob } from '../scripts/lib/artifact-store.mjs';
41
41
  import { redactBytes, redactData, redactText } from '../scripts/lib/redact.mjs';
@@ -1303,7 +1303,7 @@ const upsertWorktree = (m, wt) => upsertRow(m.db, 'worktrees', { created_at: m.n
1303
1303
  const removedWorktree = (m, wtPath, { error = null, archivedRef = null } = {}) => m.db.prepare('UPDATE worktrees SET removed_at=CASE WHEN ? IS NULL THEN ? ELSE removed_at END, remove_error=?, archived_ref=COALESCE(?,archived_ref) WHERE path=?')
1304
1304
  .run(error, m.now(), error, archivedRef, path.resolve(wtPath)).changes > 0;
1305
1305
  /**
1306
- * The worktree registry (scripts/lib/worktrees.mjs is its one caller): reserve a row for a worktree about to be created,
1306
+ * The worktree registry (scripts/lib/worktree-registry.mjs and the worktree api files are its callers): reserve a row for a worktree about to be created,
1307
1307
  * atomically against the per-repo cap. BEGIN IMMEDIATE: two dispatches never both take the last slot. `cap` null: no cap.
1308
1308
  * {ok, live} | {ok:false, reason:'worktree-cap', live, cap}
1309
1309
  */
@@ -31,14 +31,14 @@ pins:
31
31
  source: packages/grammar/package.json
32
32
  why: nivo-fe pins 0.4.11 and 0.5.0 in one workspace, starci-next-fe 0.5.1, miamia-fe 0.5.0; the runtime source is 0.8.0 (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
33
33
  '@starci/eslint-canon-be':
34
- version: 3.0.5
34
+ version: 3.0.6
35
35
  group: starci
36
36
  install: registry
37
37
  side: be
38
38
  source: packages/eslint/be/package.json
39
39
  why: '3.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 3.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
40
40
  '@starci/eslint-canon-fe':
41
- version: 8.0.5
41
+ version: 8.0.6
42
42
  group: starci
43
43
  install: registry
44
44
  side: fe
@@ -71,14 +71,14 @@ pins:
71
71
  source: packages/jest-preset/package.json
72
72
  why: '2.2.2: the unit run writes the lcov Sonar and Codecov import (services only); 2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
73
73
  '@starci/test-world':
74
- version: 1.0.3
74
+ version: 1.0.5
75
75
  group: starci
76
76
  install: registry
77
77
  side: be
78
78
  source: packages/test-world/package.json
79
- why: '1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
79
+ why: '1.0.5: a modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, and w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client. 1.0.4: world.resolve and scope.resolve take any Nest token (class, string or symbol). 1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
80
80
  '@starci/hfs':
81
- version: 4.0.5
81
+ version: 4.0.6
82
82
  group: starci
83
83
  install: registry
84
84
  side: both
@@ -818,9 +818,9 @@ slots:
818
818
  tier: integrations
819
819
  owner: true
820
820
  requires: [index.ts, "<provider>.config.ts"]
821
- tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts, nothing else
821
+ tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
822
822
  budget: {indexExports: 60}
823
- rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT]
823
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING]
824
824
  - id: be.integrations.model
825
825
  profiles: [be]
826
826
  path: "src/modules/integrations/<provider>/model/"
@@ -897,8 +897,8 @@ slots:
897
897
  tracked: tracked
898
898
  tier: e2e
899
899
  tests: e2e
900
- why: one capability module on the real database, no HTTP (SQL, transactions, concurrency, inbox claims) through useTestWorld({ modules }); run by test:integration
901
- rules: [BE_TEST_TOPOLOGY]
900
+ why: one capability module through useTestWorld({ modules }), no HTTP door of ours (SQL, transactions, concurrency, inbox claims on the real database; and every integration client, `<capability>` being its provider folder, against our real infra, a real peer app or the library fake at the network edge, with its success, its ErrorCode refusal mapping and an outage through the world, R112); run by test:integration
901
+ rules: [BE_TEST_TOPOLOGY, BE_INTEGRATION_SPEC_MISSING]
902
902
  - id: be.tests.e2e
903
903
  profiles: [be]
904
904
  path: "src/tests/e2e/<area>/*.e2e-spec.ts"
@@ -384,6 +384,16 @@ BE_FEATURE_SHAPE:
384
384
  owner: op-retry
385
385
  kind: check-finding
386
386
 
387
+ BE_INTEGRATION_SPEC_MISSING:
388
+ title: "Every integration has an integration spec that proves its client through the test world"
389
+ title_vi: "Tích hợp thiếu spec integration chứng minh client qua test world"
390
+ meaning_vi: "Thư mục `src/modules/integrations/<provider>/` (có `<provider>.config.ts`) không có `src/tests/integration/<provider>/*.integration-spec.ts` nào vừa gọi `useTestWorld({ modules })` với factory đăng ký module của chính tích hợp đó (`<Provider>Module.register`, import từ thư mục tích hợp), vừa tham chiếu enum ErrorCode của tích hợp, vừa gây một sự cố qua world (`world.infra.<service>`, `world.fake.<name>.failNext`, `world.apps.<peer>.during`, `world.interruptDatabase`)."
391
+ causes_vi:
392
+ - "Vi phạm luật R112: client của tích hợp chỉ được kiểm bằng unit spec với double, nên ánh xạ lỗi và đường sự cố của nhà cung cấp thật (hoặc fake ở biên mạng) chưa bao giờ chạy; lỗi phân loại timeout của FetchHttpClient chỉ lộ ra khi spec integration đầu tiên chạy."
393
+ nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra: thêm hoặc bổ sung spec integration của đúng thư mục provider cho phần còn thiếu (đăng ký module, enum ErrorCode, sự cố qua world); không thêm ngoại lệ."
394
+ owner: op-retry
395
+ kind: check-finding
396
+
387
397
  BE_MODULE_HANDLER_REGISTRATION:
388
398
  title: "Handler not registered exactly once"
389
399
  title_vi: "Handler chưa đăng ký đúng một lần"
@@ -0,0 +1,12 @@
1
+ // rmdir-link.mjs — the link-safe removal primitive: `cmd /d /c rmdir <link>` (no /s) removes a Windows junction or
2
+ // directory symlink as a link and never touches its target. scripts/lib/safe-remove.mjs removeLink is its one caller and
3
+ // checks afterwards that the link is gone (unlinkOnly); this file only issues the call.
4
+
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ /** Issue `cmd /d /c rmdir <p>` on Windows; elsewhere nothing (a POSIX link is unlinked by the caller). {status, error}. */
8
+ export function rmdirLink(p, { platform = process.platform } = {}) {
9
+ if (platform !== 'win32') return { status: null, error: null };
10
+ const r = spawnSync('cmd', ['/d', '/c', 'rmdir', p], { windowsHide: true, encoding: 'utf8' });
11
+ return { status: r.status, error: r.error?.message ?? null };
12
+ }
@@ -1,11 +1,11 @@
1
- // git.mjs — the one spawn the runtime's guard/kernel scripts run git through.
1
+ // scripts/api/git/lib.mjs — the one place the runtime spawns git (scripts/checks/check-layers.mjs enforces it).
2
2
  //
3
- // Every copy spelt the same options by hand - encoding:'utf8', windowsHide:true, sometimes a
4
- // timeout - with two shapes: `git args` in a cwd (shim's pathspec scans, footprint-scan,
5
- // safe-remove's prune) and `git -C dir args` (settle-landed, install, terminal-dedupe's worktree
6
- // list). gitOutput is the throwing shape (stdout text, or an Error on a non-zero exit). gitSpawn keeps the spawnSync(file, args, options) signature so an injected runner or a
7
- // spec's fake takes the same three arguments; runGit is the `-C` convenience; gitResult folds the
8
- // result into settle-landed's {ok, stdout, error} envelope.
3
+ // Every caller spelt the same options by hand - encoding:'utf8', windowsHide:true, sometimes a timeout - with two shapes:
4
+ // `git args` in a cwd and `git -C dir args`. gitOutput is the throwing shape (stdout text, or an Error on a non-zero exit).
5
+ // gitSpawn keeps the spawnSync(file, args, options) signature so an injected runner or a spec's fake takes the same three
6
+ // arguments; runGit is the `-C` convenience; gitResult folds the result into the {ok, stdout, error} envelope; gitRunner
7
+ // folds any caller's runner into {ok, stdout, stderr}. The call files beside this one (worktree-*.mjs, rev-parse.mjs, ...)
8
+ // each name one git verb.
9
9
  import { spawnSync } from 'node:child_process';
10
10
 
11
11
  /**
@@ -43,11 +43,10 @@ export function gitResult(args, options = {}) {
43
43
  }
44
44
 
45
45
  /**
46
- * git's C-quoted diff path decoded - `"b\303\251"` a `+++ ` header line prints when core.quotePath
47
- * covers the name. Octal escapes become \u00XX for JSON.parse (which answers \n, \t, \" and \\);
48
- * a value not wrapped in quotes passes through unchanged.
46
+ * A caller's git runner folded to (args, opts) -> {ok, stdout, stderr}. `git`: the caller's runner (args, {cwd}) ->
47
+ * {ok|status, stdout|out, stderr|err} (a spec's fake); null: runGit with a 5-minute timeout and a 64 MB buffer.
49
48
  */
50
- export const unquoteDiffPath = (value) =>
51
- /^".*"$/.test(value)
52
- ? JSON.parse(value.replace(/\\([0-7]{3})/g, (_, octal) => `\\u00${Number.parseInt(octal, 8).toString(16).padStart(2, '0')}`))
53
- : value;
49
+ export const gitRunner = (git = null) => (args, opts = {}) => {
50
+ const r = git ? git(args, opts) : runGit(args, { timeout: 300_000, maxBuffer: 64 * 1024 * 1024, ...opts });
51
+ return { ok: r?.ok ?? (!r?.error && r?.status === 0), stdout: String(r?.stdout ?? r?.out ?? '').trim(), stderr: String(r?.stderr ?? r?.err ?? r?.error?.message ?? r?.error ?? '').trim() };
52
+ };
@@ -18,8 +18,12 @@
18
18
  * used by the importers of that file; a re-exporting file nobody imports is a framework entry (a route file) and uses it.
19
19
  * Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with one exception (unit test
20
20
  * standard): a `<name>.service.spec.ts` (the only unit spec kind) and a `*.builder.ts` under src/tests/fixtures/builders read from
21
- * disk count as consumers, because a spec can only provide an Inject*() token or a param type the entry exports. Specs of any
22
- * other kind, e2e specs and world files still do not count. A consumer inside the owner is skipped like any inside consumer.
21
+ * disk count as consumers, because a spec can only provide an Inject*() token or a param type the entry exports. An integration
22
+ * spec (slot be.tests.integration, `src/tests/integration/<capability>/*.integration-spec.ts`) counts as a consumer of exactly one
23
+ * owner: the integration (slot be.integrations, `src/modules/integrations/<provider>/`) whose provider folder is its capability
24
+ * folder, because R112 makes that spec register the integration module and reference its ErrorCode enum. Specs of any other kind,
25
+ * an integration spec importing another owner, e2e specs and world files still do not count. A consumer inside the owner is
26
+ * skipped like any inside consumer.
23
27
  */
24
28
  import fs from 'node:fs';
25
29
  import { canonical } from './config.mjs';
@@ -168,13 +172,35 @@ function deadFiles(graph, config) {
168
172
  }
169
173
 
170
174
  const TEST_CONSUMER = /^(?:(?:src|apps)\/.+\.service\.spec\.ts|src\/tests\/fixtures\/builders\/.+\.builder\.ts)$/u;
175
+ /** The slot of integration specs and the slot of the integrations they pair with by folder (knowledge/hfs/slots.yaml). */
176
+ const INTEGRATION_SPEC_SLOT = 'be.tests.integration';
177
+ const INTEGRATION_SLOT = 'be.integrations';
171
178
 
172
- /** Pseudo edges from the unit specs of services and the fixture builders (read from disk, outside the production program) to graph files. */
179
+ /** The provider an integration spec pairs with (its `<capability>` folder), or null when `rel` is no integration spec. */
180
+ function integrationSpecProvider(graph, rel) {
181
+ const classified = graph.resolver.classifyPath(rel);
182
+ return classified.slot === INTEGRATION_SPEC_SLOT && classified.status !== 'forbidden' ? classified.bindings?.capability ?? null : null;
183
+ }
184
+
185
+ /** Whether `to` belongs to the integration of `provider` (slot be.integrations, its `<provider>` binding). */
186
+ function inIntegrationOf(graph, to, provider) {
187
+ const owner = graph.resolver.ownerOf(to);
188
+ return owner?.slot === INTEGRATION_SLOT && owner.bindings?.provider === provider;
189
+ }
190
+
191
+ /**
192
+ * Pseudo edges (read from disk, outside the production program) to graph files: from the unit specs of services and the fixture
193
+ * builders to anything, and from an integration spec only to the integration of its own provider folder.
194
+ */
173
195
  function testConsumerEdges({ context, graph, config }) {
174
196
  const { ts } = context;
175
197
  const options = context.projects?.[0]?.options ?? {};
176
198
  const edges = [];
177
- for (const rel of [...treeOf(config.root).files].filter(file => TEST_CONSUMER.test(file)).sort()) {
199
+ const consumers = [...treeOf(config.root).files]
200
+ .map(file => ({ file, provider: TEST_CONSUMER.test(file) ? null : integrationSpecProvider(graph, file) }))
201
+ .filter(({ file, provider }) => provider !== null || TEST_CONSUMER.test(file))
202
+ .sort((a, b) => a.file.localeCompare(b.file));
203
+ for (const { file: rel, provider } of consumers) {
178
204
  const abs = path.join(config.root, ...rel.split('/'));
179
205
  let text;
180
206
  try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
@@ -183,7 +209,7 @@ function testConsumerEdges({ context, graph, config }) {
183
209
  if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
184
210
  const resolved = ts.resolveModuleName(statement.moduleSpecifier.text, abs, options, ts.sys).resolvedModule?.resolvedFileName;
185
211
  const to = resolved ? graph.abs(canonical(resolved)) : null;
186
- if (to) edges.push({ from: rel, to, edge: { declaration: statement }, reexport: false });
212
+ if (to && (provider === null || inIntegrationOf(graph, to, provider))) edges.push({ from: rel, to, edge: { declaration: statement }, reexport: false });
187
213
  }
188
214
  }
189
215
  return edges;
@@ -254,7 +280,7 @@ export function checkDeadExports({ context, graph, config }) {
254
280
  ruleId: 'HFS_UNUSED_EXPORT',
255
281
  path: entry, line, column: 1,
256
282
  name, owner: owner.root, slot: owner.slot,
257
- message: `${entry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a service unit spec or a fixture builder counts besides production files).`,
283
+ message: `${entry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a service unit spec, a fixture builder, or an integration spec of this integration's own provider folder counts besides production files).`,
258
284
  });
259
285
  }
260
286
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { isIP } from 'node:net';
4
- import { gitOutput } from '../../lib/git.mjs';
4
+ import { gitOutput } from '../../api/git/lib.mjs';
5
5
  import { repositoryName } from '../../lib/repo-identity.mjs';
6
6
  import { braceVariants } from '../../lib/glob.mjs';
7
7
  import { createSlotResolver, loadSlotManifest, openHfs } from '../../lib/hfs-slots.mjs';
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { gitOutput } from '../../lib/git.mjs';
3
+ import { gitOutput } from '../../api/git/lib.mjs';
4
4
 
5
5
  /**
6
6
  * HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
@@ -27,6 +27,7 @@
27
27
  // HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
28
28
  // HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
29
29
  // HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
30
+ // BE_INTEGRATION_SPEC_MISSING (R112, hfs-rules/integration-specs.mjs) an integration with no integration spec that registers its module, maps its refusals and drives an outage
30
31
  // FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
31
32
  // HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
32
33
  // HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
@@ -48,12 +49,13 @@ import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture
48
49
  import { parseYaml } from '../../engine/yaml.mjs';
49
50
  import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
50
51
  import { allowsFile } from './hfs-allows.mjs';
51
- import { gitOutput } from './git.mjs';
52
+ import { gitOutput } from '../api/git/lib.mjs';
52
53
  import { posixPath } from './path-key.mjs';
53
54
  import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
54
55
  import { contractFindings } from './hfs-rules/contract.mjs';
55
56
  import { depFindings } from './hfs-rules/deps.mjs';
56
57
  import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
58
+ import { integrationSpecFindings } from './hfs-rules/integration-specs.mjs';
57
59
  import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
58
60
  import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
59
61
  import { pipelineFindings } from './hfs-rules/pipeline.mjs';
@@ -84,7 +86,7 @@ export const CHECK_CODES = Object.freeze([
84
86
  'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
85
87
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
86
88
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
87
- 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
89
+ 'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
88
90
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
89
91
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
90
92
  ...REFUSAL_CODES,
@@ -265,6 +267,7 @@ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, sco
265
267
  findings.push(
266
268
  ...depFindings({ repoRoot, files: all }),
267
269
  ...peerIntegrationFindings({ repoRoot, files: all }),
270
+ ...integrationSpecFindings({ repoRoot, files: all }),
268
271
  ...pipelineFindings({ repoRoot, files, pins }),
269
272
  ...testTopologyFindings({ repoRoot, files }),
270
273
  ...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
@@ -0,0 +1,222 @@
1
+ // integration-specs.mjs - BE_INTEGRATION_SPEC_MISSING (R112): every integration of a back end has an integration spec that proves
2
+ // its client through the test world. An integration is the slot be.integrations: `src/modules/integrations/<provider>/` holding
3
+ // `<provider>.config.ts` (its required config boundary). Its spec is the slot be.tests.integration of the same folder name:
4
+ // `src/tests/integration/<provider>/*.integration-spec.ts`. At least one of those specs, read as a TypeScript syntax tree (never
5
+ // matched by name), must
6
+ // 1. call `useTestWorld({ modules })` where `modules` is an array (inline, a local const, or an exported const of another file,
7
+ // spreads followed) holding a factory whose body calls `<X>.register(...)` with `<X>` imported from that integration;
8
+ // 2. reference an enum the integration exports (its ErrorCode enum): an identifier imported from the integration whose export
9
+ // resolves to an `enum` declaration, used outside the import;
10
+ // 3. drive an outage through the world handle the call answers: `<world>.infra.<service>.cut|latency|during|connection(...)`, `<world>.fake.<name>.failNext(...)`,
11
+ // `<world>.apps.<name>.during(...)` or `<world>.interruptDatabase(...)`.
12
+ // The finding sits on the integration folder. Imports resolve through the side's tsconfig (paths included) with the app's own
13
+ // TypeScript, the one every back end installs; the runtime's TypeScript is the fallback.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { findPackage, requirePackage } from '../package-at.mjs';
18
+ import { found, readText } from './read.mjs';
19
+
20
+ export const INTEGRATION_SPEC_MISSING = 'BE_INTEGRATION_SPEC_MISSING';
21
+
22
+ const CONFIG_FILE = /^((?:[^/]+\/)*?)src\/modules\/integrations\/([^/]+)\/\2\.config\.ts$/;
23
+ const SPEC_SUFFIX = '.integration-spec.ts';
24
+ const OUTAGE_CALLS = Object.freeze({ fake: 'failNext', apps: 'during' });
25
+ /** The outage verbs of `world.infra.<service>` (`connection` leads to one connection's cut/restore/during). */
26
+ const INFRA_OUTAGES = new Set(['cut', 'latency', 'during', 'connection']);
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ /** The TypeScript compiler of the app (else of the runtime), or null. */
30
+ function typescriptFor(repoRoot) {
31
+ const located = findPackage([repoRoot, HERE], ['typescript']);
32
+ return located ? requirePackage(located) : null;
33
+ }
34
+
35
+ /** The compiler options of the side (its tsconfig.json, `extends` and `paths` resolved by TypeScript itself). */
36
+ function optionsOf(ts, sideRoot) {
37
+ const file = path.join(sideRoot, 'tsconfig.json');
38
+ if (!fs.existsSync(file)) return {};
39
+ const read = ts.readConfigFile(file, ts.sys.readFile);
40
+ if (read.error) return {};
41
+ return ts.parseJsonConfigFileContent(read.config, ts.sys, sideRoot).options;
42
+ }
43
+
44
+ /** One parsed file: its syntax tree and its imports (local name -> { source, imported }). */
45
+ function parse(ts, repoRoot, rel) {
46
+ const text = readText(repoRoot, rel);
47
+ if (text === null) return null;
48
+ const sourceFile = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
49
+ const imports = new Map();
50
+ for (const statement of sourceFile.statements) {
51
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
52
+ const named = statement.importClause?.namedBindings;
53
+ if (!named || !ts.isNamedImports(named)) continue;
54
+ for (const element of named.elements) imports.set(element.name.text, { source: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text, node: element });
55
+ }
56
+ return { rel, sourceFile, imports };
57
+ }
58
+
59
+ /** Walks every node under `node`. */
60
+ function walk(ts, node, visit) {
61
+ visit(node);
62
+ ts.forEachChild(node, (child) => walk(ts, child, visit));
63
+ }
64
+
65
+ /** The names of a property chain `a.b.c` from its root identifier, or null when the chain does not start at an identifier. */
66
+ function chainOf(ts, expression) {
67
+ const names = [];
68
+ let current = expression;
69
+ while (ts.isPropertyAccessExpression(current)) {
70
+ names.unshift(current.name.text);
71
+ current = current.expression;
72
+ }
73
+ return ts.isIdentifier(current) ? [current.text, ...names] : null;
74
+ }
75
+
76
+ /**
77
+ * The judge of one app: resolves an import of a parsed file to a repository-relative path, finds the top-level const of a file,
78
+ * and reads files once.
79
+ */
80
+ function judgeOf(ts, repoRoot, side) {
81
+ const options = optionsOf(ts, path.join(repoRoot, side));
82
+ const cache = new Map();
83
+ const parsed = (rel) => {
84
+ if (!cache.has(rel)) cache.set(rel, parse(ts, repoRoot, rel));
85
+ return cache.get(rel);
86
+ };
87
+ const resolve = (fromRel, source) => {
88
+ const resolved = ts.resolveModuleName(source, path.join(repoRoot, fromRel), options, ts.sys).resolvedModule?.resolvedFileName;
89
+ if (!resolved) return null;
90
+ const rel = path.relative(repoRoot, resolved).split(path.sep).join('/');
91
+ return rel.startsWith('..') ? null : rel;
92
+ };
93
+ /** The initializer of `const <name> = ...` at the top level of `file`. */
94
+ const constOf = (file, name) => {
95
+ for (const statement of file.sourceFile.statements) {
96
+ if (!ts.isVariableStatement(statement)) continue;
97
+ for (const declaration of statement.declarationList.declarations) {
98
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
99
+ }
100
+ }
101
+ return null;
102
+ };
103
+ return { parsed, resolve, constOf };
104
+ }
105
+
106
+ /** Whether `rel` lies in the integration folder `folder` (its index or any file of it). */
107
+ const insideFolder = (rel, folder) => rel !== null && (rel === `${folder}/index.ts` || rel.startsWith(`${folder}/`));
108
+
109
+ /** The array elements `expression` evaluates to in `file`, each with the file it is written in (identifiers, spreads and imports followed). */
110
+ function elementsOf(ts, judge, file, expression, depth = 0) {
111
+ if (!expression || depth > 6) return [];
112
+ let node = expression;
113
+ while (ts.isAsExpression(node) || ts.isSatisfiesExpression?.(node) || ts.isParenthesizedExpression(node)) node = node.expression;
114
+ if (ts.isArrayLiteralExpression(node)) {
115
+ return node.elements.flatMap((element) => (ts.isSpreadElement(element) ? elementsOf(ts, judge, file, element.expression, depth + 1) : [{ file, node: element }]));
116
+ }
117
+ if (!ts.isIdentifier(node)) return [];
118
+ const local = judge.constOf(file, node.text);
119
+ if (local) return elementsOf(ts, judge, file, local, depth + 1);
120
+ const imported = file.imports.get(node.text);
121
+ if (!imported) return [];
122
+ const target = judge.resolve(file.rel, imported.source);
123
+ const other = target ? judge.parsed(target) : null;
124
+ return other ? elementsOf(ts, judge, other, judge.constOf(other, imported.imported), depth + 1) : [];
125
+ }
126
+
127
+ /** Whether a factory element calls `<X>.register(...)` with `<X>` imported (in the element's file) from the integration folder. */
128
+ function registersIntegration(ts, judge, element, folder) {
129
+ let registers = false;
130
+ walk(ts, element.node, (node) => {
131
+ if (registers || !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
132
+ if (node.expression.name.text !== 'register' || !ts.isIdentifier(node.expression.expression)) return;
133
+ const binding = element.file.imports.get(node.expression.expression.text);
134
+ if (binding && insideFolder(judge.resolve(element.file.rel, binding.source), folder)) registers = true;
135
+ });
136
+ return registers;
137
+ }
138
+
139
+ /** Whether `name`, exported by the module `rel`, is declared as an enum there or in the file it is re-exported from. */
140
+ function exportsEnum(ts, judge, rel, name, depth = 0) {
141
+ const file = rel ? judge.parsed(rel) : null;
142
+ if (!file || depth > 4) return false;
143
+ for (const statement of file.sourceFile.statements) {
144
+ if (ts.isEnumDeclaration(statement) && statement.name.text === name) return true;
145
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
146
+ for (const element of statement.exportClause.elements) {
147
+ if (element.name.text !== name) continue;
148
+ const original = (element.propertyName ?? element.name).text;
149
+ if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
150
+ return exportsEnum(ts, judge, rel, original, depth + 1);
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /** What one spec proves about the integration in `folder`: { modules, errorCode, outage }. */
157
+ function judgeSpec(ts, judge, rel, folder) {
158
+ const spec = judge.parsed(rel);
159
+ const result = { modules: false, errorCode: false, outage: false };
160
+ if (!spec) return result;
161
+ const worlds = new Set();
162
+ walk(ts, spec.sourceFile, (node) => {
163
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'useTestWorld' || !spec.imports.has('useTestWorld')) return;
164
+ const [argument] = node.arguments;
165
+ if (!argument || !ts.isObjectLiteralExpression(argument)) return;
166
+ const modules = argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules');
167
+ if (!modules) return;
168
+ if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) worlds.add(node.parent.name.text);
169
+ if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder))) result.modules = true;
170
+ });
171
+ const enumBindings = new Set(
172
+ [...spec.imports].filter(([, binding]) => {
173
+ const target = judge.resolve(rel, binding.source);
174
+ return insideFolder(target, folder) && exportsEnum(ts, judge, target, binding.imported);
175
+ }).map(([local]) => local),
176
+ );
177
+ walk(ts, spec.sourceFile, (node) => {
178
+ if (ts.isIdentifier(node) && enumBindings.has(node.text) && !ts.isImportSpecifier(node.parent)) result.errorCode = true;
179
+ if (!ts.isPropertyAccessExpression(node)) return;
180
+ const chain = chainOf(ts, node);
181
+ if (!chain || !worlds.has(chain[0])) return;
182
+ const [, area, name, call] = chain;
183
+ const called = ts.isCallExpression(node.parent) && node.parent.expression === node;
184
+ if (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call) && called) result.outage = true;
185
+ else if (area === 'interruptDatabase' && called) result.outage = true;
186
+ else if (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area] && called) result.outage = true;
187
+ });
188
+ return result;
189
+ }
190
+
191
+ const MISSING_PARTS = Object.freeze({
192
+ modules: 'call useTestWorld({ modules }) with a factory that registers the integration module (<Provider>Module.register) imported from the integration',
193
+ errorCode: "reference the integration's ErrorCode enum (its refusal mapping)",
194
+ outage: 'drive an outage through the world: world.infra.<service>, world.fake.<name>.failNext(...), world.apps.<peer>.during(...) or world.interruptDatabase(...)',
195
+ });
196
+
197
+ /** The findings of R112 over the tracked paths `files` of the app at `repoRoot`. */
198
+ export function integrationSpecFindings({ repoRoot, files }) {
199
+ const integrations = files.map((file) => CONFIG_FILE.exec(file)).filter(Boolean).map(([, side, provider]) => ({ side, provider, folder: `${side}src/modules/integrations/${provider}` }));
200
+ if (integrations.length === 0) return [];
201
+ const ts = typescriptFor(repoRoot);
202
+ const findings = [];
203
+ for (const { side, provider, folder } of integrations) {
204
+ const specDir = `${side}src/tests/integration/${provider}/`;
205
+ const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort();
206
+ if (specs.length === 0) {
207
+ findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ is an integration with no integration spec: add ${specDir}<name>${SPEC_SUFFIX} that ${MISSING_PARTS.modules}, ${MISSING_PARTS.errorCode}, and ${MISSING_PARTS.outage}.`, { provider, missing: ['spec'] }));
208
+ continue;
209
+ }
210
+ if (ts === null) {
211
+ findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ cannot be judged: no TypeScript resolves from the app; install the app's dependencies.`, { provider, missing: ['typescript'] }));
212
+ continue;
213
+ }
214
+ const judge = judgeOf(ts, repoRoot, side);
215
+ const verdicts = specs.map((spec) => ({ spec, ...judgeSpec(ts, judge, spec, folder) }));
216
+ if (verdicts.some((verdict) => verdict.modules && verdict.errorCode && verdict.outage)) continue;
217
+ const best = [...verdicts].sort((a, b) => Number(b.modules) + Number(b.errorCode) + Number(b.outage) - (Number(a.modules) + Number(a.errorCode) + Number(a.outage)))[0];
218
+ const missing = Object.keys(MISSING_PARTS).filter((part) => !best[part]);
219
+ findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ has integration specs (${specs.join(', ')}), but none proves the integration: ${best.spec} must also ${missing.map((part) => MISSING_PARTS[part]).join('; and ')}.`, { provider, missing }));
220
+ }
221
+ return findings;
222
+ }
@@ -4,7 +4,7 @@
4
4
  // Read-only: it walks the file system and asks git, nothing else.
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { gitOutput } from './git.mjs';
7
+ import { gitOutput } from '../api/git/lib.mjs';
8
8
  import { posixPath } from './path-key.mjs';
9
9
 
10
10
  /** Directory names no tree check enters: git's own store and installed packages. */
@@ -0,0 +1,25 @@
1
+ // scripts/lib/package-at.mjs — a package the runtime does not ship (playwright, esbuild, tailwindcss) comes from the
2
+ // project that owns it: node resolution from that project's directory, walking up its node_modules.
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ /** The package.json of `name` as resolved from `dir`, or null. */
7
+ export const packageAt = (dir, name) => {
8
+ try { return createRequire(path.join(dir, 'package.json')).resolve(`${name}/package.json`); } catch { return null; }
9
+ };
10
+
11
+ /** The first of `names` resolvable from the first of `dirs` that has one: {name, root, packageFile, version} or null. */
12
+ export function findPackage(dirs, names) {
13
+ for (const dir of dirs.filter(Boolean)) {
14
+ for (const name of names) {
15
+ const packageFile = packageAt(dir, name);
16
+ if (!packageFile) continue;
17
+ const require = createRequire(packageFile);
18
+ return { name, root: path.dirname(packageFile), packageFile, version: require(packageFile).version };
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /** The CommonJS entry of a package found by findPackage. */
25
+ export const requirePackage = (found) => createRequire(found.packageFile)(found.name);
@@ -3,7 +3,7 @@
3
3
  // they carry the same README title, the same stack-declaration project name and the same sibling repositories.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { gitOutput } from './git.mjs';
6
+ import { gitOutput } from '../api/git/lib.mjs';
7
7
 
8
8
  const git = (root, args) => gitOutput(args, { cwd: root }).trim();
9
9
  const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
@@ -18,11 +18,11 @@
18
18
  import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
- import { spawnSync } from 'node:child_process';
22
21
  import { fileURLToPath } from 'node:url';
23
22
  import { sleepSync } from './sleep-sync.mjs';
24
23
  import { samePath } from './path-key.mjs';
25
- import { gitSpawn } from './git.mjs';
24
+ import { gitSpawn } from '../api/git/lib.mjs';
25
+ import { rmdirLink } from '../api/fs/rmdir-link.mjs';
26
26
  import { artifactHoldReason } from './artifact-hold.mjs';
27
27
  import { realpathOr } from './fs-kind.mjs';
28
28
 
@@ -186,7 +186,7 @@ export function removeLink(p) {
186
186
  if (WIN) {
187
187
  let st = null;
188
188
  try { st = fs.lstatSync(p); } catch { return true; }
189
- if (st.isDirectory() || st.isSymbolicLink()) spawnSync('cmd', ['/d', '/c', 'rmdir', p], { windowsHide: true, encoding: 'utf8' });
189
+ if (st.isDirectory() || st.isSymbolicLink()) rmdirLink(p);
190
190
  }
191
191
  return unlinkOnly(p);
192
192
  }
@@ -213,7 +213,7 @@ export function mainCheckoutDamage(before, after) {
213
213
  }
214
214
 
215
215
  /**
216
- * The link step of every worktree removal (git's here, Orca's in scripts/lib/worktrees.mjs removeOrcaWorktree): every link
216
+ * The link step of every worktree removal (git's here, Orca's in scripts/api/orca/worktree-remove.mjs removeOrcaWorktree): every link
217
217
  * under `target` found WITHOUT following one (linksUnder), each removed as a link (removeLink: `cmd /c rmdir <link>`, never
218
218
  * /s), outermost first, then a re-scan that must find ZERO. {ok, links, errors: [{path, code, message}]}; ok false: a link
219
219
  * is stuck and the caller removes nothing.