@starci/hfs 4.7.27 → 4.7.29

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 (57) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -3
  3. package/package.json +7 -1
  4. package/release/candidate-ci.mjs +3 -21
  5. package/runtime/engine/yaml.mjs +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  7. package/runtime/knowledge/hfs/rules.yaml +913 -637
  8. package/runtime/knowledge/hfs/slots.yaml +21 -14
  9. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  10. package/runtime/packages/cli/src/catalog.generated.mjs +146 -58
  11. package/runtime/scripts/api/git/check-ignore.mjs +5 -0
  12. package/runtime/scripts/gates/runtime-host.mjs +83 -0
  13. package/runtime/scripts/gates/sonar-gate.mjs +251 -0
  14. package/runtime/scripts/gates/starcistacks-ci.mjs +55 -0
  15. package/runtime/scripts/gates/starcistacks-form.mjs +26 -0
  16. package/runtime/scripts/gates/starcistacks-runtime.mjs +2 -0
  17. package/runtime/scripts/gates/starcistacks-value-file.mjs +17 -0
  18. package/runtime/scripts/gates/starcistacks.mjs +499 -0
  19. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  20. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  21. package/runtime/scripts/hfs/check.mjs +4 -1
  22. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  23. package/runtime/scripts/hfs/manifest-shape.mjs +1 -3
  24. package/runtime/scripts/hfs/rule-catalog.mjs +7 -10
  25. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  27. package/runtime/scripts/hfs/rules/bypass-markers.mjs +24 -0
  28. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  29. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  30. package/runtime/scripts/lib/bypass-markers.mjs +116 -0
  31. package/runtime/scripts/lib/check-code-loosening.mjs +128 -0
  32. package/runtime/scripts/lib/example-refs.mjs +205 -0
  33. package/runtime/scripts/lib/failure-code-reads.mjs +100 -0
  34. package/runtime/scripts/lib/gate-loosening.mjs +52 -8
  35. package/runtime/scripts/lib/json-formats.mjs +34 -0
  36. package/runtime/scripts/lib/package-at.mjs +1 -1
  37. package/runtime/scripts/lib/properties.mjs +16 -0
  38. package/runtime/scripts/lib/provision-control-failure.mjs +32 -0
  39. package/runtime/scripts/lib/sonar-new-code.mjs +58 -0
  40. package/runtime/scripts/lib/starciwork-boundary.mjs +164 -0
  41. package/runtime/scripts/lib/work-edition.mjs +40 -0
  42. package/scaffold/app.mjs +3 -0
  43. package/scaffold/service.mjs +1 -1
  44. package/scaffold/test-world.mjs +125 -0
  45. package/src/main.mjs +1 -1
  46. package/sync/format.mjs +1 -1
  47. package/sync/hygiene.mjs +16 -4
  48. package/templates/app/hooks/husky/pre-commit +8 -7
  49. package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +32 -0
  50. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +14 -0
  51. package/templates/be/test-world/integration/readiness.integration-spec.ts.tpl +28 -0
  52. package/templates/be/test-world/world/global-setup.ts.tpl +1 -0
  53. package/templates/be/test-world/world/global-teardown.ts.tpl +1 -0
  54. package/templates/be/test-world/world/test-apps.options.ts.tpl +62 -0
  55. package/templates/be/test-world/world/test-world.config.ts.tpl +20 -0
  56. package/templates/be/test-world/world/use-test-world.ts.tpl +2 -0
  57. package/upgrade/index.mjs +48 -9
@@ -0,0 +1,100 @@
1
+ // failure-code-reads.mjs - the UPPER_SNAKE literals of a script that READ a value another producer made, or only describe one in a comment,
2
+ // and so are not emitted failure codes (scripts/checks/check-failure-codes.mjs adds these quote offsets to its exact-AST exclusions).
3
+ // - a literal inside a comment (a name mentioned in prose is not emitted);
4
+ // - an operand of ===, !==, == or != against a non-literal, or a `case` label (the code is compared, not produced);
5
+ // - an argument of a string or collection query (endsWith, startsWith, includes, indexOf, has, get);
6
+ // - an element of an array literal whose only use here is `.includes(x)` / `.indexOf(x)` against a non-literal (a membership test);
7
+ // - the symbol half of a (symbol, package) argument pair such as `providersOf(root, 'APP_GUARD', '@nestjs/core')`: a name another package exports;
8
+ // - a literal the script declares as an enum member by identity (`Scalar.QUOTE_DOUBLE = "QUOTE_DOUBLE"`), wherever it then appears: the value of an enum-like constant, not a code;
9
+ // - the value of a property named `type` (a discriminator of someone else's API, such as a token type).
10
+ // Emission sites (code:, reason:, finding(...), thrown messages, code lists) are never matched here. Proven by the package spec
11
+ // src/failure-code-reads.spec.mjs over the bundled copy of this file.
12
+
13
+ const EQUALITY = new Set(['===', '!==', '==', '!=']);
14
+ const QUERY_METHODS = new Set(['endsWith', 'startsWith', 'includes', 'indexOf', 'has', 'get']);
15
+ const MEMBERSHIP_METHODS = new Set(['includes', 'indexOf']);
16
+
17
+ const isText = (t, node) => t.isStringLiteralLike(node);
18
+
19
+ /** Whether `node` is the operand of an equality against a value that is not itself a literal. */
20
+ function comparedValue(t, source, node) {
21
+ const parent = node.parent;
22
+ if (!parent || !t.isBinaryExpression(parent) || !EQUALITY.has(parent.operatorToken.getText(source))) return false;
23
+ const other = parent.left === node ? parent.right : parent.left;
24
+ return !isText(t, other);
25
+ }
26
+
27
+ /** Whether `node` is an argument of a string/collection query call such as `key.endsWith('X_Y')`. */
28
+ function queryArgument(t, node) {
29
+ const call = node.parent;
30
+ return Boolean(call && t.isCallExpression(call) && call.arguments.includes(node)
31
+ && t.isPropertyAccessExpression(call.expression) && QUERY_METHODS.has(call.expression.name.text));
32
+ }
33
+
34
+ /** Whether `node` is an element of an array literal that is only tested with `.includes(value)` for a non-literal value. */
35
+ function membershipElement(t, node) {
36
+ const array = node.parent;
37
+ if (!array || !t.isArrayLiteralExpression(array)) return false;
38
+ const access = array.parent;
39
+ if (!access || !t.isPropertyAccessExpression(access) || access.expression !== array || !MEMBERSHIP_METHODS.has(access.name.text)) return false;
40
+ const call = access.parent;
41
+ return Boolean(call && t.isCallExpression(call) && call.expression === access && call.arguments.length >= 1 && !isText(t, call.arguments[0]));
42
+ }
43
+
44
+ const PACKAGE_SPECIFIER = /^(?:@[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?|\.{1,2}\/[\w./-]+)$/;
45
+
46
+ /** Whether `node` is a call argument directly followed by a package or relative module specifier: the name of a symbol that module exports. */
47
+ function importedSymbol(t, node) {
48
+ const call = node.parent;
49
+ if (!call || !t.isCallExpression(call)) return false;
50
+ const next = call.arguments[call.arguments.indexOf(node) + 1];
51
+ return Boolean(next && isText(t, next) && PACKAGE_SPECIFIER.test(next.text));
52
+ }
53
+
54
+ const caseLabel = (t, node) => Boolean(node.parent && t.isCaseClause(node.parent) && node.parent.expression === node);
55
+
56
+ const discriminator = (t, node) => Boolean(node.parent && t.isPropertyAssignment(node.parent) && node.parent.initializer === node
57
+ && t.isIdentifier(node.parent.name) && node.parent.name.text === 'type');
58
+
59
+ /** The [pos, end) ranges of every comment of `source`. */
60
+ function commentRanges(t, source) {
61
+ const text = source.text, ranges = [];
62
+ const visit = (node) => {
63
+ for (const range of t.getLeadingCommentRanges(text, node.getFullStart()) ?? []) ranges.push([range.pos, range.end]);
64
+ for (const range of t.getTrailingCommentRanges(text, node.end) ?? []) ranges.push([range.pos, range.end]);
65
+ t.forEachChild(node, visit);
66
+ };
67
+ visit(source);
68
+ return ranges;
69
+ }
70
+
71
+ /**
72
+ * The offsets to leave out of UPPER literal discovery for a parsed script: the opening quote of every read literal, and the start
73
+ * of every `patterns` match (RegExp with the g flag) that lies inside a comment.
74
+ */
75
+ export function nonEmittingOffsets(t, source, patterns = []) {
76
+ const offsets = new Set();
77
+ const members = new Set();
78
+ const collect = (node) => {
79
+ if (t.isBinaryExpression(node) && node.operatorToken.kind === t.SyntaxKind.EqualsToken && t.isPropertyAccessExpression(node.left)
80
+ && isText(t, node.right) && node.right.text === node.left.name.text) members.add(node.right.text);
81
+ t.forEachChild(node, collect);
82
+ };
83
+ collect(source);
84
+ const visit = (node) => {
85
+ if (isText(t, node) && (members.has(node.text) || comparedValue(t, source, node) || queryArgument(t, node) || membershipElement(t, node) || caseLabel(t, node) || importedSymbol(t, node) || discriminator(t, node))) {
86
+ offsets.add(node.getStart(source));
87
+ }
88
+ t.forEachChild(node, visit);
89
+ };
90
+ visit(source);
91
+ const ranges = commentRanges(t, source);
92
+ if (ranges.length) {
93
+ for (const pattern of patterns) {
94
+ for (const match of source.text.matchAll(pattern)) {
95
+ if (ranges.some(([pos, end]) => match.index >= pos && match.index < end)) offsets.add(match.index);
96
+ }
97
+ }
98
+ }
99
+ return offsets;
100
+ }
@@ -9,6 +9,9 @@
9
9
  // spec-deleted a spec file deleted in a change that also changes product code
10
10
  // assertion-weakened a spec that loses more assertions than it gains in a change that also changes product code
11
11
  // spec-skipped a skip marker added to a spec in a change that also changes product code
12
+ // bypass-added, check-weakened, scope-widened (and check-removed over code): the code of a check is a gate too. A check script, rule module,
13
+ // canon template, lint package, workflow or hook (modules/kernel/gate-loosening.yaml checkCode) that gains a bypass marker,
14
+ // loses enforcement lines or ban-list rows, or gains an exemption is judged by scripts/lib/check-code-loosening.mjs
12
15
  // A change that renames paths (a git rename, `git diff -M`) may MOVE the entries that name them: a removed line and an added line of the
13
16
  // same file that are equal once every renamed prefix of the removed line is rewritten to its new name are one entry moved, not a check
14
17
  // removed and an exception added (renamedPrefixesOf, movedLinesOf). A moved entry that also changes in any other way (a widened glob, an
@@ -18,21 +21,53 @@
18
21
  // Only what a release shipped can be loosened (loosensReleased): a check, a spec or an assertion added after the release and folded
19
22
  // away before the next one never was a gate of a release.
20
23
  import { byCodeUnit } from './list.mjs';
24
+ import { checkCodeFindings, isCheckCode } from './check-code-loosening.mjs';
21
25
 
22
26
  const LIST_ITEM = /^\s*-\s+\S/;
23
27
  const NUMBER_LINE = /^\s*(?:-\s+)?([A-Za-z][\w.-]*):\s*(-?\d+(?:\.\d+)?)\s*(?:#.*)?$/;
28
+ /** A line of a JSON or JavaScript config as the YAML number line above reads it: no quotes around the key, no trailing comma or line comment. */
29
+ function plainNumberLine(line) {
30
+ const comment = line.indexOf('//');
31
+ const code = (comment < 0 ? line : line.slice(0, comment)).trimEnd();
32
+ return (code.endsWith(',') ? code.slice(0, -1) : code).replace(/^(\s*(?:-\s+)?)["']([^"']+)["']:/, '$1$2:');
33
+ }
34
+
35
+ const concatenates = (line) => typeof line === 'string' && line.trimEnd().endsWith('+');
36
+
37
+ /** Records one changed line of `current`; returns the removed text (the next line's `above`), or null. */
38
+ function recordLine(current, line, section, above) {
39
+ if (line.startsWith('deleted file mode')) { current.deleted = true; return null; }
40
+ if (line.startsWith('+') && !line.startsWith('+++')) {
41
+ const text = line.slice(1);
42
+ current.added.push(text);
43
+ current.under.set(text, [...(current.under.get(text) ?? []), section]);
44
+ return null;
45
+ }
46
+ if (!line.startsWith('-') || line.startsWith('---')) return null;
47
+ const text = line.slice(1);
48
+ current.removed.push(text);
49
+ if (concatenates(above) || concatenates(text)) current.operands.add(text);
50
+ return text;
51
+ }
24
52
 
25
- /** The files of a unified diff: [{file, deleted, added[], removed[]}] (changed lines without their marker). */
53
+ /**
54
+ * The files of a unified diff: [{file, deleted, added[], removed[], under}] (changed lines without their marker). `under` maps an added line to the
55
+ * hunk headers it was added under (git prints the nearest line above the hunk that starts a word, so the key a YAML list item sits in).
56
+ * `operands` holds the removed lines that are an operand of a string concatenation: a line that ends in `+`, or one whose removed line just
57
+ * above it in the same hunk ends in `+` (a message being printed, never a row of a ban list).
58
+ */
26
59
  export function parseDiff(text) {
27
60
  const files = [];
28
61
  let current = null;
62
+ let section = '';
63
+ let above = null;
29
64
  for (const line of String(text ?? '').split(/\r?\n/)) {
30
65
  const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
31
- if (header) { current = { file: header[2], deleted: false, added: [], removed: [] }; files.push(current); continue; }
66
+ if (header) { current = { file: header[2], deleted: false, added: [], removed: [], operands: new Set(), under: new Map() }; files.push(current); above = null; continue; }
32
67
  if (!current) continue;
33
- if (line.startsWith('deleted file mode')) current.deleted = true;
34
- else if (line.startsWith('+') && !line.startsWith('+++')) current.added.push(line.slice(1));
35
- else if (line.startsWith('-') && !line.startsWith('---')) current.removed.push(line.slice(1));
68
+ const hunk = /^@@ .*? @@ ?(.*)$/.exec(line);
69
+ if (hunk) { section = hunk[1]; above = null; continue; }
70
+ above = recordLine(current, line, section, above);
36
71
  }
37
72
  return files;
38
73
  }
@@ -163,7 +198,7 @@ function checksRemoved(entry) {
163
198
  });
164
199
  }
165
200
 
166
- const numbersOf = (lines) => lines.map((line) => NUMBER_LINE.exec(line)).filter(Boolean).map((match) => ({ key: match[1], value: Number(match[2]) }));
201
+ const numbersOf = (lines) => lines.map((line) => NUMBER_LINE.exec(plainNumberLine(line))).filter(Boolean).map((match) => ({ key: match[1], value: Number(match[2]) }));
167
202
 
168
203
  /** The thresholds of a gate file moved the loose way. */
169
204
  function thresholdsLowered(entry, rules) {
@@ -198,6 +233,12 @@ function specFindings(entry, files, rules) {
198
233
 
199
234
  const changesProduct = (files, rules) => files.some((entry) => !isSpec(entry.file, rules) && rules.productPrefixes.some((prefix) => entry.file.startsWith(prefix)));
200
235
 
236
+ /** The findings of one file that is a gate file (its lists and numbers) or the code of a check (its numbers; its bypasses are checkCodeFindings'). */
237
+ function gateFindings(entry, rules) {
238
+ if (rules.gateFiles.includes(entry.file)) return [...checksRemoved(entry), ...thresholdsLowered(entry, rules)];
239
+ return isCheckCode(entry.file, rules) ? thresholdsLowered(entry, rules) : [];
240
+ }
241
+
201
242
  /**
202
243
  * Every loosening of the change `diffText`, sorted: [{id, kind, file, detail}]. `rules` is modules/kernel/gate-loosening.yaml; `renamed`
203
244
  * is the change's renamed prefixes (renamedPrefixesOf), whose moved entries are no loosening.
@@ -206,10 +247,10 @@ export function looseningsOf(diffText, rules, { renamed = [] } = {}) {
206
247
  const files = parseDiff(diffText).map((entry) => movedLinesOf(entry, renamed));
207
248
  const product = changesProduct(files, rules);
208
249
  const found = files.flatMap((entry) => [
209
- ...(rules.gateFiles.includes(entry.file) ? [...checksRemoved(entry), ...thresholdsLowered(entry, rules)] : []),
250
+ ...gateFindings(entry, rules),
210
251
  ...(rules.allowlistFiles.includes(entry.file) ? entry.added.filter((value) => LIST_ITEM.test(value)).map((value) => ({ kind: 'allowlist-added', file: entry.file, detail: clip(trimmed(value)) })) : []),
211
252
  ...(product && isSpec(entry.file, rules) ? specFindings(entry, files, rules) : []),
212
- ]);
253
+ ]).concat(checkCodeFindings(files, rules));
213
254
  return found.map((finding) => ({ ...finding, id: `${finding.kind}:${finding.file}:${finding.detail}` })).sort((a, b) => byCodeUnit(a.id, b.id));
214
255
  }
215
256
 
@@ -233,6 +274,7 @@ const shownDetail = (detail) => (detail.endsWith('…') ? detail.slice(0, -1) :
233
274
  /**
234
275
  * Whether `finding` loosens the state released at `base`, read through `read(rev, file)` (text, or null when the revision holds no such
235
276
  * file); `rules` gives the assertion markers. A finding stays only when
277
+ * bypass-added the added line is still in the file at HEAD (a bypass folded away before the next release never shipped)
236
278
  * check-removed the removed list item is an item of that gate file at the release commit
237
279
  * spec-deleted the spec exists at the release commit
238
280
  * spec-skipped the spec exists at the release commit
@@ -240,6 +282,8 @@ const shownDetail = (detail) => (detail.endsWith('…') ? detail.slice(0, -1) :
240
282
  * (every other kind loosens whenever its file existed at the release).
241
283
  */
242
284
  export function loosensReleased(finding, { base, read, rules }) {
285
+ // A bypass is judged by whether it still stands: one added and folded away before the next release never shipped.
286
+ if (finding.kind === 'bypass-added') return String(read('HEAD', finding.file) ?? '').split('\n').some((line) => line.trim() === finding.text);
243
287
  const before = read(base, finding.file);
244
288
  if (before === null) return false;
245
289
  if (finding.kind === 'check-removed') return before.split('\n').some((line) => line.trim().startsWith(shownDetail(finding.detail)));
@@ -0,0 +1,34 @@
1
+ // json-formats.mjs - the JSON files whose format a tool requires, declared once by rule (a basename, a tool-fixed folder or a managed
2
+ // fragment) and not file by file. A file of a listed format is not authored declarative source: npm, tsc, turbo, next-intl, Claude Code,
3
+ // the stack tooling and Keycloak read it as JSON and nothing else, so no YAML or JS form of it exists. Everything else that is authored
4
+ // config belongs in YAML, and the json-exceptions check (scripts/checks/check-json-exceptions.mjs) refuses it. Proven by the package spec
5
+ // src/json-formats.spec.mjs over the bundled copy of this file.
6
+
7
+ /** The base name of a slash path. */
8
+ const baseOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
9
+
10
+ /**
11
+ * The declared formats: `id`, the `tool` that fixes the format and a `match(rel)` over a skill-relative slash path. A format names a
12
+ * kind of file (a base name or a folder the tool reads by position), never one path of this repository.
13
+ */
14
+ export const JSON_FORMATS = Object.freeze([
15
+ { id: 'npm-manifest', tool: 'npm', match: (rel) => baseOf(rel) === 'package.json' || baseOf(rel) === 'package-lock.json' },
16
+ { id: 'typescript-config', tool: 'tsc', match: (rel) => /^tsconfig(\.[A-Za-z0-9-]+)*\.json$/.test(baseOf(rel)) },
17
+ { id: 'shared-tsconfig-preset', tool: 'tsc (extends)', match: (rel) => /^packages\/tsconfig\/[^/]+\.json$/.test(rel) },
18
+ { id: 'turbo-pipeline', tool: 'turbo', match: (rel) => baseOf(rel) === 'turbo.json' },
19
+ { id: 'nest-cli', tool: '@nestjs/cli', match: (rel) => baseOf(rel) === 'nest-cli.json' },
20
+ { id: 'tree-kind', tool: 'starci hfs (hfs.json at a tree root)', match: (rel) => baseOf(rel) === 'hfs.json' },
21
+ { id: 'claude-code-settings', tool: 'Claude Code', match: (rel) => /(^|\/)\.claude\/settings\.json$/.test(rel) },
22
+ { id: 'i18n-message-catalog', tool: 'next-intl', match: (rel) => /(^|\/)modules\/i18n\/messages\/[a-z]{2}(-[A-Za-z]+)?\.json$/.test(rel) },
23
+ { id: 'stack-environment', tool: 'starci stack (.starcistacks layout)', match: (rel) => /(^|\/)\.starcistacks\/[^/]+\/environment\.json$/.test(rel) },
24
+ { id: 'stack-infra-metadata', tool: 'starci stack (.starcistacks layout)', match: (rel) => /(^|\/)\.starcistacks\/[^/]+\/infra\/metadata\.json$/.test(rel) },
25
+ { id: 'keycloak-realm-import', tool: 'Keycloak realm import', match: (rel) => /(^|\/)\.starcistacks\/[^/]+\/infra\/compose\/realm-[^/]+\.json$/.test(rel) },
26
+ { id: 'package-scripts-fragment', tool: 'npm (scripts rendered into package.json)', match: (rel) => /^packages\/hfs\/templates\/app\/package-scripts[^/]*\/[^/]+\.json$/.test(rel) },
27
+ { id: 'async-event-contract', tool: 'starci app emit', match: (rel) => /(^|\/)be\/contracts\/[^/]+\/events(\.pin)?\.json$/.test(rel) },
28
+ { id: 'model-scorecard-snapshot', tool: 'scripts/agent/benchmark-snapshot.mjs', match: (rel) => /^benchmark\/snapshots\/[^/]+\.json$/.test(rel) },
29
+ ]);
30
+
31
+ /** The declared format that fixes `rel`, or null: the file is authored JSON the check must refuse. */
32
+ export function jsonFormatOf(rel) {
33
+ return JSON_FORMATS.find((format) => format.match(rel)) ?? null;
34
+ }
@@ -14,7 +14,7 @@ export const packageAt = (dir, name) => {
14
14
  const RUNTIME_STARCI_BIN = path.join(skillRoot, 'packages', 'cli', 'bin', 'starci.mjs');
15
15
 
16
16
  /** The `starci` bin of the @starci/cli that `dir` installs (a product runs its own pinned CLI), else this runtime's own. */
17
- export function starciBin(dir) {
17
+ function starciBin(dir) {
18
18
  const packageFile = packageAt(dir, '@starci/cli');
19
19
  if (packageFile) {
20
20
  try {
@@ -0,0 +1,16 @@
1
+ // properties.mjs — the .properties file reader (key=value lines, # and ! comments).
2
+ import fs from 'node:fs';
3
+
4
+ /** `file` as {key: value}: blank lines and #/! comments skipped, the first = or : splits. Unreadable file → {}. */
5
+ export function readProperties(file) {
6
+ const out = {};
7
+ let text;
8
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
9
+ for (const raw of text.split(/\r?\n/)) {
10
+ const line = raw.trim();
11
+ if (!line || line.startsWith('#') || line.startsWith('!')) continue;
12
+ const at = line.search(/[=:]/);
13
+ if (at > 0) out[line.slice(0, at).trim()] = line.slice(at + 1).trim();
14
+ }
15
+ return out;
16
+ }
@@ -0,0 +1,32 @@
1
+ // provision-control-failure.mjs - why `starci provision <plan|show|accept>` could not read or accept a prepared operation, as one public code and one
2
+ // value-free detail line. Pure: the caller (scripts/gates/provision-operation.mjs runProvisionControl) names the stage that failed and passes the error;
3
+ // this never serializes an error message, which may quote a provider body or a value, only the error's own identifier.
4
+ // The case that motivated it: an operation prepared by a lane runtime keeps its plan blob in THAT runtime's blob store, and the published CLI reading it
5
+ // from another runtime found the ledger row but not the blob; a blanket `provision-control-unavailable` hid that.
6
+
7
+ /** The stages of the control verb, in the order it runs them. */
8
+ export const PROVISION_CONTROL_STAGES = Object.freeze(['ledger', 'plan', 'target', 'accept']);
9
+
10
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/u;
11
+ const SHA = /^[a-f0-9]{64}$/u;
12
+
13
+ /** The error's own code (ERR_SQLITE_ERROR, ENOENT) or class name, never its message; null when it has neither. */
14
+ export function errorIdentifier(error) {
15
+ return [error?.code, error?.name].find((value) => typeof value === 'string' && IDENTIFIER.test(value)) ?? null;
16
+ }
17
+
18
+ /**
19
+ * {code, detail} for a failure of the control verb at `stage`. A plan blob the ledger names but this runtime's blob store does not hold is
20
+ * `provision-plan-blob-missing`; every other failure stays `provision-control-unavailable` with the stage and the error's identifier.
21
+ */
22
+ export function provisionControlFailure(error, { stage, planSha = null, operationId = null } = {}) {
23
+ if (!PROVISION_CONTROL_STAGES.includes(stage)) throw new TypeError(`unknown provision control stage: ${String(stage)}`);
24
+ const id = errorIdentifier(error);
25
+ if (stage === 'plan' && id === 'ENOENT') {
26
+ const blob = typeof planSha === 'string' && SHA.test(planSha) ? `the plan blob ${planSha.slice(0, 12)}` : 'the plan blob';
27
+ const operation = typeof operationId === 'string' && IDENTIFIER.test(operationId) ? ` of operation ${operationId}` : '';
28
+ return { code: 'provision-plan-blob-missing', detail: `${blob}${operation} is not in this runtime's blob store: the operation was prepared by another runtime; run the control with the runtime that prepared it, or prepare the operation again with this one` };
29
+ }
30
+ const named = id ? ' (' + id + ')' : '';
31
+ return { code: 'provision-control-unavailable', detail: `the ${stage} stage failed${named}; the operation journal holds its state` };
32
+ }
@@ -0,0 +1,58 @@
1
+ // sonar-new-code.mjs - the New Code definition a Sonar project is ensured with, and how each provider stores and reads it back. Pure.
2
+ // Without a New Code definition a SonarQube Cloud project's gate status is NONE and the scanner's quality-gate wait reports the gate
3
+ // "not set" (alpha.27: two example projects carried the gate but no New Code definition). The canonical
4
+ // period is knowledge/sonar-gate.yaml `gate.newCodePeriod` of the repository's edition; a stack target may declare its own
5
+ // `resource.newCodePeriod` of the same shape. SonarQube Server and Community Build store it through api/new_code_periods; SonarQube
6
+ // Cloud has no such web service and keeps it as the project setting `sonar.leak.period` (a number of days, or `previous_version`).
7
+
8
+ /** The period types a declaration may name, and whether each carries a value. */
9
+ const NEW_CODE_TYPES = Object.freeze({ NUMBER_OF_DAYS: 'days', PREVIOUS_VERSION: 'none' });
10
+ /** The setting SonarQube Cloud keeps a project's New Code definition in. */
11
+ export const CLOUD_NEW_CODE_SETTING = 'sonar.leak.period';
12
+ const MAX_DAYS = 90;
13
+
14
+ /** A period of a closed shape: {type: NUMBER_OF_DAYS, value: 1..90} or {type: PREVIOUS_VERSION}; throws on anything else. */
15
+ export function newCodePeriodOf(period) {
16
+ if (!period || typeof period !== 'object' || Array.isArray(period)) throw new Error('Invalid declared new code period');
17
+ const keys = Object.keys(period);
18
+ if (!keys.every((key) => key === 'type' || key === 'value') || !Object.hasOwn(NEW_CODE_TYPES, period.type)) throw new Error('Invalid declared new code period');
19
+ if (period.type === 'PREVIOUS_VERSION') {
20
+ if (period.value !== undefined) throw new Error('Invalid declared new code period');
21
+ return { type: 'PREVIOUS_VERSION' };
22
+ }
23
+ if (!Number.isInteger(period.value) || period.value < 1 || period.value > MAX_DAYS) throw new Error('Invalid declared new code period');
24
+ return { type: 'NUMBER_OF_DAYS', value: period.value };
25
+ }
26
+
27
+ /** The period a project target is ensured with: its own `resource.newCodePeriod` when declared, else the canonical one of the gate. */
28
+ export function declaredNewCodePeriod(resource, canonical) {
29
+ return newCodePeriodOf(resource?.newCodePeriod ?? canonical);
30
+ }
31
+
32
+ /** The value SonarQube Cloud stores in `sonar.leak.period` for `period`. */
33
+ export const cloudLeakValue = (period) => (period.type === 'PREVIOUS_VERSION' ? 'previous_version' : String(period.value));
34
+
35
+ /**
36
+ * The write that sets `period` on `projectKey` for `provider`: {kind: 'setting', key, value} on SonarQube Cloud, else
37
+ * {kind: 'period', form: {project, type, value?}} for api/new_code_periods/set.
38
+ */
39
+ export function newCodeWrite(provider, projectKey, period) {
40
+ if (provider === 'sonar-cloud') return { kind: 'setting', key: CLOUD_NEW_CODE_SETTING, value: cloudLeakValue(period) };
41
+ return { kind: 'period', form: { project: projectKey, type: period.type, ...(period.value === undefined ? {} : { value: String(period.value) }) } };
42
+ }
43
+
44
+ /**
45
+ * Whether a provider's readback holds `period`: on SonarQube Cloud the api/settings/values answer ({settings: [{key, value}]}),
46
+ * elsewhere the api/new_code_periods/show answer ({type, value}). A missing definition, another type or another value is no match.
47
+ */
48
+ export function newCodeMatches(provider, readback, period) {
49
+ if (provider === 'sonar-cloud') {
50
+ const row = (readback?.settings ?? []).find((setting) => setting?.key === CLOUD_NEW_CODE_SETTING);
51
+ return row?.value === cloudLeakValue(period);
52
+ }
53
+ if (readback?.type !== period.type) return false;
54
+ return period.value === undefined || String(readback.value) === String(period.value);
55
+ }
56
+
57
+ /** The public proof of an ensured period: `NUMBER_OF_DAYS:30` or `PREVIOUS_VERSION`. */
58
+ export const newCodeProof = (period) => (period.value === undefined ? period.type : `${period.type}:${period.value}`);
@@ -0,0 +1,164 @@
1
+ // starciwork-boundary.mjs — the executable form of the .starciwork boundary (docs/ledger-db.md,
2
+ // modules/schemas/work-layout.yaml shape.productPaths). A repository's .starciwork holds product content only:
3
+ // the explicit path list below. Every other path is agent data (reports, checks, captures, draw rounds, UAT runs,
4
+ // logs, caches, ledgers, worktrees), which lives in runtime.sqlite rows and content-addressed blobs outside
5
+ // every repository. STARCIWORK_GITIGNORE is the .starciwork/.gitignore content of a product repository.
6
+ //
7
+ // Paths are relative to the .starciwork root, '/'-separated.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ // The one source of the .starciwork/.gitignore text: the app root template `starci app sync` renders (packages/hfs/templates).
12
+ const TEMPLATE = path.join(import.meta.dirname, '..', '..', 'packages', 'hfs', 'templates', 'app', 'starciwork.gitignore');
13
+ const LITE_TEMPLATE = path.join(path.dirname(TEMPLATE), 'starciwork.gitignore-lite');
14
+
15
+ // The record families that hold nothing but index.yaml and evidence.yaml (ui, impl, uat and ac have their own rows).
16
+ const PLAIN_FAMILIES = Object.freeze(['br', 'fr', 'nfr', 'data', 'journey', 'decision', 'sds', 'contract', 'integration', 'gap', 'event']);
17
+ const RECORD_FILE = /^(index|evidence)\.yaml$/;
18
+ const UAT_FILE = /^(index\.yaml|evidence\.yaml|accounts\.yaml|fixtures\.yaml|seed\.sql|cleanup\.sql)$/;
19
+ const PLAIN = new RegExp(`^(${PLAIN_FAMILIES.join('|')})$`);
20
+ const ANY = /^.+$/;
21
+ const REST = '**';
22
+ // One or more record-name folders: a record may nest by name segment where a group needs it (br/title/required,
23
+ // fr/cart/add, ui/<group>/<screen>, impl/<repo>/<group>/<name>). A nesting folder is never assets/ or an agent-data
24
+ // folder (DENY_SEGMENT), so a capture or run can never pass as a record.
25
+ const NEST = '+';
26
+ const NEST_SEGMENT = /^(?!assets$).+$/;
27
+
28
+ // Each pattern is a list of segment matchers: a string (exact), a RegExp (one segment), '+' (one or more record-name
29
+ // folders) or '**' (one or more segments of anything). Order follows §5.1.
30
+ const PRODUCT_PATTERNS = Object.freeze([
31
+ ['.gitignore'], ['.gitattributes'], ['workspace.yaml'], ['index.yaml'],
32
+ ['brand', RECORD_FILE], ['brand', 'assets', REST],
33
+ ['shell', RECORD_FILE],
34
+ ['_resources', /^(environments|identities|fixtures|runtimes)$/, ANY, REST],
35
+ ['_resources', 'grammar-captures', REST],
36
+ ['_derived', /^(index\.yaml|frontier\.md|critique\.yaml|critique\.md)$/],
37
+ ['features', 'index.yaml'],
38
+ ['features', ANY, 'index.yaml'],
39
+ ['features', ANY, /^(br|ac|fr|nfr|data|journey|decision|sds|ui|impl|uat|contract|integration|gap|event)$/, 'index.yaml'],
40
+ ['features', ANY, PLAIN, NEST, RECORD_FILE],
41
+ ['features', ANY, 'br', NEST, 'ac', NEST, RECORD_FILE],
42
+ ['features', ANY, 'ui', NEST, RECORD_FILE],
43
+ ['features', ANY, 'ui', NEST, /^(flow|lifecycle-interface)\.yaml$/],
44
+ ['features', ANY, 'ui', NEST, 'assets', REST],
45
+ ['features', ANY, 'impl', ANY, 'index.yaml'],
46
+ ['features', ANY, 'impl', ANY, NEST, RECORD_FILE],
47
+ ['features', ANY, 'uat', NEST, UAT_FILE],
48
+ ].map(Object.freeze));
49
+
50
+ // Agent output that is refused even where a pattern above would admit it: draw rounds inside a ui record's
51
+ // assets, stray report copies, evidence bundles, evidence/ captures and UAT runs anywhere, temp JSON.
52
+ const DENY_SEGMENT = /^(draw-loop|evidence|runs|kernel-evidence|kernel-strays|kernel-approvals)$/;
53
+ const DENY_FILE = /^(report.*\.json|.*\.tmp\.json)$/;
54
+
55
+ const segs = (rel) => String(rel ?? '').replaceAll('\\', '/').split('/').filter((s) => s && s !== '.');
56
+ const fits = (matcher, seg) => (typeof matcher === 'string' ? matcher === seg : matcher.test(seg));
57
+
58
+ function denied(parts, { dir = false } = {}) {
59
+ // The last segment of a file path is a file name: `evidence.yaml` is a record file, never an evidence/ bundle.
60
+ const dirs = dir ? parts : parts.slice(0, -1);
61
+ if (dirs.some((s) => DENY_SEGMENT.test(s))) return true;
62
+ return !dir && parts.length > 0 && DENY_FILE.test(parts.at(-1));
63
+ }
64
+
65
+ // Match parts[i..] against pattern[p..]. `prefix`: parts is a directory, true when some file under it could match.
66
+ function walk(pattern, p, parts, i, prefix) {
67
+ if (i === parts.length) return prefix ? p < pattern.length : p === pattern.length;
68
+ if (p === pattern.length) return false;
69
+ const m = pattern[p];
70
+ if (m === REST) return prefix || parts.length > i;
71
+ if (m === NEST) {
72
+ if (!NEST_SEGMENT.test(parts[i])) return false;
73
+ // consume this folder, then either stop nesting or keep nesting
74
+ return walk(pattern, p + 1, parts, i + 1, prefix) || walk(pattern, p, parts, i + 1, prefix);
75
+ }
76
+ return fits(m, parts[i]) && walk(pattern, p + 1, parts, i + 1, prefix);
77
+ }
78
+ const matchFile = (pattern, parts) => walk(pattern, 0, parts, 0, false);
79
+
80
+ /** True when the FILE at `rel` (relative to .starciwork) is product content §5.1 admits. */
81
+ export function isProductPath(rel) {
82
+ const parts = segs(rel);
83
+ if (!parts.length || denied(parts)) return false;
84
+ return PRODUCT_PATTERNS.some((p) => matchFile(p, parts));
85
+ }
86
+
87
+ /** The lite app boundary: its optional Work tree holds .gitignore, workspace.yaml and brand/** only. */
88
+ export function isLiteProductPath(rel, { dir = false } = {}) {
89
+ const parts = segs(rel);
90
+ return (!dir && parts.length === 1 && ['.gitignore', 'workspace.yaml'].includes(parts[0]))
91
+ || (parts[0] === 'brand' && (parts.length > 1 || dir));
92
+ }
93
+
94
+ const ROOT_CACHE_DIRS = new Set(['settle-parity', 'settle-tail', 'runtime', 'canon-seams']);
95
+ const ROOT_CACHE_FILE = /^(.*\.tmp\.json|tmp-.*\.json|scan-i18n.*\.json|runtime-budget\.json|ledger-anchor\.json|supervisor-precheck\.mjs)$/;
96
+
97
+ /**
98
+ * The §5.2 category of a KNOWN agent-data path, or null when the path is not one. Known agent data — the
99
+ * categories scripts/work/validate/check-example-work.mjs refuses in a .starciwork tree [HFS_AGENT_DATA_TRACKED]:
100
+ * ledger, logs-db, worktrees, kernel-evidence, kernel-strays, evidence-bundle, capture (impl captures),
101
+ * layout-capture (shell/assets), uat-run, draw-round, interface-audit (features/<f>/operations/), stray-report,
102
+ * cache. A path that is neither known agent data nor on the §5.1 list
103
+ * (isProductPath) is drift: product records in a retired layout, kept and reported, never removed by a script.
104
+ */
105
+ const rootAgentDataCategory = (parts, dir, top) => {
106
+ if (parts.length === 1 && !dir && top.startsWith('runtime.sqlite')) return 'ledger';
107
+ if (parts.length === 1 && !dir && top.startsWith('logs.sqlite')) return 'logs-db';
108
+ if (top === 'worktrees') return 'worktrees';
109
+ if (top === 'kernel-evidence') return 'kernel-evidence';
110
+ if (top === 'kernel-strays' || top === 'kernel-approvals') return 'kernel-strays';
111
+ if (ROOT_CACHE_DIRS.has(top) && (dir || parts.length > 1)) return 'cache';
112
+ if (parts.length === 1 && !dir && ROOT_CACHE_FILE.test(top)) return 'cache';
113
+ return null;
114
+ };
115
+
116
+ const nestedAgentDataCategory = (dirs, name) => {
117
+ if (dirs.includes('draw-loop')) return 'draw-round';
118
+ if (dirs.includes('runs') && dirs[0] === 'features' && dirs.includes('uat')) return 'uat-run';
119
+ if (dirs.includes('evidence')) return 'evidence-bundle';
120
+ if (dirs[0] === 'features' && dirs.includes('operations')) return 'interface-audit';
121
+ if (dirs[0] === 'features' && dirs.includes('impl') && dirs.includes('assets')) return 'capture';
122
+ if (dirs[0] === 'shell' && dirs[1] === 'assets') return 'layout-capture';
123
+ if (name && /^report.*\.json$/.test(name)) return 'stray-report';
124
+ if (name?.endsWith('.tmp.json')) return 'cache';
125
+ return null;
126
+ };
127
+
128
+ export function agentDataCategory(rel, { dir = false } = {}) {
129
+ const parts = segs(rel);
130
+ if (!parts.length) return null;
131
+ const top = parts[0];
132
+ const dirs = dir ? parts : parts.slice(0, -1);
133
+ const name = dir ? null : parts.at(-1);
134
+ return rootAgentDataCategory(parts, dir, top) ?? nestedAgentDataCategory(dirs, name);
135
+ }
136
+
137
+ /**
138
+ * The .starciwork/.gitignore of a product repository: deny everything at the root, re-admit
139
+ * the §5.1 roots, then deny agent output inside them. Tracked files are not affected by an ignore rule; a tracked
140
+ * agent file is removed by the change that drops it, not by this ignore.
141
+ *
142
+ * The template is read on first use, never at import: a caller that only needs the boundary predicates
143
+ * (cli.mjs reaches this module through check-example-work.mjs) must keep loading on a tree that carries no
144
+ * packages/hfs/templates copy; the read still fails loudly the moment the text itself is asked for.
145
+ */
146
+ let cachedLines;
147
+ const gitignoreLines = () => (cachedLines ??= Object.freeze(fs.readFileSync(TEMPLATE, 'utf8').trimEnd().split(/\r?\n/)));
148
+ const frozenWrite = () => { throw new TypeError('STARCIWORK_GITIGNORE is a frozen view of the hfs template'); };
149
+ export const STARCIWORK_GITIGNORE = new Proxy([], {
150
+ get: (_t, p) => Reflect.get(gitignoreLines(), p),
151
+ has: (_t, p) => Reflect.has(gitignoreLines(), p),
152
+ ownKeys: () => Reflect.ownKeys(gitignoreLines()),
153
+ getOwnPropertyDescriptor: (_t, p) => {
154
+ const d = Reflect.getOwnPropertyDescriptor(gitignoreLines(), p);
155
+ // The proxy target holds none of these slots: every index must report configurable, and 'length' must keep
156
+ // the writable shape of its non-configurable target slot.
157
+ if (!d) return d;
158
+ return p === 'length' ? { ...d, writable: true } : { ...d, configurable: true };
159
+ },
160
+ set: frozenWrite,
161
+ deleteProperty: frozenWrite,
162
+ defineProperty: frozenWrite,
163
+ setPrototypeOf: frozenWrite,
164
+ });
@@ -0,0 +1,40 @@
1
+ // The edition boundary for Work readers: lite keeps workspace identity, brand records and their authored assets.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isLiteProductPath } from './starciwork-boundary.mjs';
5
+ import { foldCase } from './path-key.mjs';
6
+ import { WORK_DIR_NAME } from './roots.mjs';
7
+
8
+ const appTemplatesRoot = path.resolve(import.meta.dirname, '..', '..', 'packages', 'hfs', 'templates', 'app');
9
+
10
+ /** Unrendered Work seeds in this runtime's app skeletons are templates, not product records. */
11
+ export function isWorkTemplatePath(file) {
12
+ const [skeleton, work] = foldCase(path.relative(appTemplatesRoot, path.resolve(file))).replaceAll('\\', '/').split('/');
13
+ return (skeleton === 'skeleton' || skeleton === 'skeleton-lite') && work === WORK_DIR_NAME;
14
+ }
15
+
16
+ /** Whether the app owning this Work root declares lite. Missing declarations retain the full reader contract. */
17
+ export function isLiteWorkRoot(workRoot) {
18
+ if (!workRoot) return false;
19
+ try {
20
+ const declaration = JSON.parse(fs.readFileSync(path.join(path.dirname(path.resolve(workRoot)), 'hfs.json'), 'utf8'));
21
+ return declaration?.kind === 'app' && declaration?.edition === 'lite';
22
+ } catch { return false; }
23
+ }
24
+
25
+ /** Whether a reader may open `file`: external files are unaffected; lite Work paths keep workspace.yaml and brand/**. */
26
+ export function workPathAllowed(workRoot, file, { dir = false } = {}) {
27
+ if (!isLiteWorkRoot(workRoot)) return true;
28
+ const rel = path.relative(path.resolve(workRoot), path.resolve(file)).replaceAll('\\', '/');
29
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) return true;
30
+ return !rel || isLiteProductPath(rel, { dir });
31
+ }
32
+
33
+ /** Every forbidden entry directly below a lite Work root, including empty directories. Never read their contents. */
34
+ export function liteWorkForbiddenPaths(workRoot) {
35
+ if (!isLiteWorkRoot(workRoot)) return [];
36
+ let entries;
37
+ try { entries = fs.readdirSync(workRoot, { withFileTypes: true }); } catch { return []; }
38
+ return entries.filter(entry => !isLiteProductPath(entry.name, { dir: entry.isDirectory() }))
39
+ .map(entry => path.join(workRoot, entry.name));
40
+ }
package/scaffold/app.mjs CHANGED
@@ -21,6 +21,7 @@ import { dbTypesPath, generateDbTypes } from '../emit/db-types.mjs';
21
21
  import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, writeTargets } from '../sync/index.mjs';
22
22
  import { ScaffoldError } from './service.mjs';
23
23
  import { SUPABASE_PORT_VARIABLES, supabasePortVars } from './supabase-ports.mjs';
24
+ import { testWorldFiles } from './test-world.mjs';
24
25
  import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, WORKSPACE_LINT, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
25
26
  import { isLinkLike } from '../runtime/scripts/api/fs/is-link-like.mjs';
26
27
  import { unlinkOnly } from '../runtime/scripts/api/fs/lib.mjs';
@@ -685,6 +686,8 @@ export function scaffoldApp({ name, into, presets, edition = 'full', authCallbac
685
686
  { path: `fe/packages/${name}-${pkg}/tsconfig.build.json`, content: jsonText(fePackageBuildTsconfig()) },
686
687
  ])),
687
688
  ...['app', 'be', 'fe'].flatMap(scope => skeletonOf(scope, app, { sonarGate: sonarGateName(parseYaml(fs.readFileSync(SONAR_GATE_FILE, 'utf8')), app.edition), timestamp, registryVisibility, ...supabasePortVars(name) })),
689
+ // A full app has a test world (integration, e2e and contract are rows of its release gate); lite has none (ruling:R212/case-3, hosting amended by owner:lite-production-swarm).
690
+ ...(app.edition === 'lite' ? [] : testWorldFiles(app)),
688
691
  ];
689
692
  const writes = scaffoldWrites(root, original);
690
693
  try {
@@ -38,7 +38,7 @@ const words = name => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z
38
38
  export const pascalOf = name => words(name).map(word => word[0].toUpperCase() + word.slice(1).toLowerCase()).join('');
39
39
  /** `InjectPrimaryEntityManager` -> `PRIMARY_ENTITY_MANAGER`. */
40
40
  const tokenNameOf = decorator => words(decorator.replace(/^Inject/, '')).map(word => word.toUpperCase()).join('_');
41
- const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
41
+ export const camelOf = name => { const pascal = pascalOf(name); return pascal[0].toLowerCase() + pascal.slice(1); };
42
42
 
43
43
  const SERVICE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
44
44