@starci/eslint-canon-be 3.0.8 → 3.0.9

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 (147) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +12 -6
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/path.mjs +0 -6
  28. package/lib/persistence.mjs +11 -18
  29. package/lib/ports.mjs +5 -2
  30. package/lib/specifier.mjs +27 -0
  31. package/lib/transactions.mjs +37 -0
  32. package/lib/transport-slots.mjs +8 -3
  33. package/lib/unit-spec.mjs +63 -2
  34. package/module-layering.mjs +3 -24
  35. package/module-shape.mjs +2 -2
  36. package/naming.mjs +2 -2
  37. package/package.json +3 -1
  38. package/project-graph.mjs +5 -2
  39. package/projections.mjs +117 -0
  40. package/query-safety.mjs +1 -1
  41. package/realtime.mjs +181 -0
  42. package/runtime/engine/config.mjs +494 -0
  43. package/runtime/engine/invalid-config.mjs +15 -0
  44. package/runtime/engine/orca-config.mjs +27 -0
  45. package/runtime/knowledge/hfs/canon-pins.yaml +160 -29
  46. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  47. package/runtime/knowledge/hfs/slots.yaml +912 -120
  48. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  49. package/runtime/knowledge/sonar-gate.yaml +52 -18
  50. package/runtime/modules/models/registry.yaml +591 -0
  51. package/runtime/modules/models/runtimes.yaml +701 -0
  52. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  53. package/runtime/scripts/api/git/config-get.mjs +10 -0
  54. package/runtime/scripts/api/git/lib.mjs +16 -26
  55. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  56. package/runtime/scripts/api/git/remote.mjs +5 -0
  57. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  58. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  59. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  60. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  61. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  62. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  63. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  64. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  65. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  66. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  67. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  68. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  69. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  70. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  71. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  72. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  73. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  75. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  77. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  79. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  80. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  81. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  85. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  86. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  87. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  88. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  89. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  90. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  91. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  92. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  93. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  94. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  95. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  96. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  97. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  98. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  99. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  100. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  101. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  102. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  103. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  104. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  105. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  106. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  107. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  108. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  109. package/runtime/scripts/hfs/param-names.mjs +18 -0
  110. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  111. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  112. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  113. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  114. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  115. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  116. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  117. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  118. package/runtime/scripts/hfs/slots.mjs +78 -108
  119. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  120. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  121. package/runtime/scripts/hfs/view.mjs +3 -2
  122. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  123. package/runtime/scripts/lib/git.mjs +36 -0
  124. package/runtime/scripts/lib/i18n.mjs +86 -0
  125. package/runtime/scripts/lib/json.mjs +13 -3
  126. package/runtime/scripts/lib/language.mjs +24 -5
  127. package/runtime/scripts/lib/list.mjs +20 -0
  128. package/runtime/scripts/lib/path-key.mjs +57 -2
  129. package/runtime/scripts/lib/read-text.mjs +34 -0
  130. package/runtime/scripts/lib/regex.mjs +4 -0
  131. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  132. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  133. package/runtime/scripts/lib/stack-services.mjs +7 -31
  134. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  135. package/runtime/scripts/lib/walk.mjs +4 -3
  136. package/schema-authority.mjs +14 -12
  137. package/service-deps.mjs +7 -13
  138. package/service-isolation.mjs +72 -0
  139. package/size-budget.mjs +1 -1
  140. package/test-world.mjs +5 -4
  141. package/testing.mjs +27 -24
  142. package/transport.mjs +12 -9
  143. package/unit-spec.mjs +1 -1
  144. package/user-copy.mjs +5 -4
  145. package/webhooks.mjs +173 -0
  146. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  147. package/runtime/scripts/lib/test-secrets.mjs +0 -116
@@ -0,0 +1,36 @@
1
+ // git.mjs — pure helpers over git's text output, spawn results and env. The git spawn itself lives in scripts/api/git/ (lib.mjs and its call files).
2
+
3
+ /**
4
+ * git's C-quoted diff path decoded - `"b\303\251"` a `+++ ` header line prints when core.quotePath
5
+ * covers the name. Octal escapes become \u00XX for JSON.parse (which answers \n, \t, \" and \\);
6
+ * a value not wrapped in quotes passes through unchanged.
7
+ */
8
+ export const unquoteDiffPath = (value) =>
9
+ /^".*"$/.test(value)
10
+ ? JSON.parse(value.replace(/\\([0-7]{3})/g, (_, octal) => `\\u00${Number.parseInt(octal, 8).toString(16).padStart(2, '0')}`))
11
+ : value;
12
+
13
+ // git's repository-local variables (git rev-parse --local-env-vars). A hook or alias run in a linked worktree exports
14
+ // GIT_DIR=<main>/.git/worktrees/<wt>; a spec inheriting it pointed every fixture git at the LIVE .claude repo and re-inited
15
+ // it core.bare=true (2026-09-29, a6f60352c). tests/setup/isolated-registry.mjs drops the same list.
16
+ export const GIT_LOCAL_ENV_VARS = ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_IMPLICIT_WORK_TREE', 'GIT_PREFIX', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE'];
17
+ /** `parent` (the caller's env) without git's repository-local variables (GIT_CONFIG_KEY_n/VALUE_n go with GIT_CONFIG_COUNT). Pure. */
18
+ export function withoutGitLocalEnv(parent) {
19
+ const env = { ...parent };
20
+ for (const key of Object.keys(env)) if (GIT_LOCAL_ENV_VARS.includes(key) || /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) delete env[key];
21
+ return env;
22
+ }
23
+
24
+ /** A git spawn result (a scripts/api/git call file's) as {ok, stdout, error}: ok only on a clean exit, error from stderr or the spawn. Pure. */
25
+ export const gitResultOf = (r) => ({
26
+ ok: !r.error && r.status === 0,
27
+ stdout: r.stdout ?? '',
28
+ error: (r.stderr ?? '').trim() || String(r.error?.message ?? (r.status == null ? 'timed out' : `exit ${r.status}`)),
29
+ });
30
+
31
+ /** A git spawn result's stdout text; throws the spawn error, or an Error naming `what` with git's stderr and exit status, unless git exited 0. Pure. */
32
+ export function gitOutputOf(r, what = 'git') {
33
+ if (r.error) throw r.error;
34
+ if (r.status !== 0) throw Object.assign(new Error(`${what} exited ${r.status}: ${(r.stderr ?? '').trim()}`), { status: r.status, stderr: r.stderr });
35
+ return r.stdout;
36
+ }
@@ -0,0 +1,86 @@
1
+ // i18n.mjs - the ONE mechanism for text the owner must read in Vietnamese. Runtime source, comments and messages are English;
2
+ // a message the owner reads (a Telegram notice, a why line, a progress report) is written once in English at its call site and
3
+ // translated through a DECLARED catalog keyed from that English source: modules/i18n/messages/<area>.yaml, a list of
4
+ // `{en, vi}` entries. Each area belongs to a scope: `runtime` for Node callers or `ui` for the generated browser catalog.
5
+ // The `vi` field is declared in scripts/lib/language.mjs, so it is the only Vietnamese source. There is no per-module
6
+ // `TEXT.vi` table and no second catalog mechanism.
7
+ //
8
+ // const tr = translator(config.language); // 'vi' translates, anything else returns the English source
9
+ // tr('Workflow {id} needs your decision', { id })
10
+ //
11
+ // A placeholder is `{name}`; the English and the Vietnamese of one entry carry exactly the same placeholders
12
+ // (scripts/checks/check-i18n-catalog.mjs). An English source with no entry is returned as is: a missing translation degrades
13
+ // to English, never to a blank.
14
+ import { escapeRegExp } from './regex.mjs';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { skillRoot } from '../../engine/runtime-root.mjs';
18
+ import { parseYaml } from '../../engine/yaml.mjs';
19
+ import { DEFAULT_OWNER_LANGUAGE, loadConfig } from '../../engine/config.mjs';
20
+
21
+ export const CATALOG_DIR = 'modules/i18n/messages';
22
+ export const PLACEHOLDER = /\{([A-Za-z_][\w]*)\}/g;
23
+
24
+ const cache = new Map();
25
+
26
+ /** The catalog files of a runtime root: repository-relative paths, sorted. */
27
+ export function catalogFiles(root = skillRoot) {
28
+ const dir = path.join(root, ...CATALOG_DIR.split('/'));
29
+ try { return fs.readdirSync(dir).filter((name) => /\.ya?ml$/.test(name)).sort().map((name) => `${CATALOG_DIR}/${name}`); } catch { return []; }
30
+ }
31
+
32
+ /** Every entry in one catalog scope: `[{ en, vi, area, scope, file }]`. */
33
+ export function catalogEntries(root = skillRoot, { scope = 'runtime' } = {}) {
34
+ return catalogFiles(root).flatMap((rel) => {
35
+ const doc = parseYaml(fs.readFileSync(path.join(root, ...rel.split('/')), 'utf8'));
36
+ const documentScope = typeof doc?.scope === 'string' ? doc.scope : 'runtime';
37
+ if (documentScope !== scope) return [];
38
+ return (Array.isArray(doc?.messages) ? doc.messages : []).map((entry) => ({
39
+ en: entry?.en, vi: entry?.vi, area: doc?.area, scope: documentScope, file: rel,
40
+ }));
41
+ });
42
+ }
43
+
44
+ /** One scoped catalog as a Map from the English source to the Vietnamese text (cached per root and scope). */
45
+ export function loadCatalog(root = skillRoot, { scope = 'runtime' } = {}) {
46
+ const key = `${root}\0${scope}`;
47
+ if (!cache.has(key)) cache.set(key, new Map(catalogEntries(root, { scope }).filter((e) => typeof e.en === 'string' && typeof e.vi === 'string').map((e) => [e.en, e.vi])));
48
+ return cache.get(key);
49
+ }
50
+
51
+ /** Forget the cached catalogs (a spec that rewrites a catalog file). */
52
+ export const resetCatalogCache = () => cache.clear();
53
+
54
+ /** The names each `pattern` match captures (group 1) in `text`, in match order (`sort` sorts them). */
55
+ export const captureNames = (text, pattern, { sort = false } = {}) => {
56
+ const names = [...String(text).matchAll(pattern)].map((m) => m[1]);
57
+ return sort ? names.sort() : names;
58
+ };
59
+
60
+ /** The names of the placeholders of a message. */
61
+ export const placeholdersOf = (text) => captureNames(text, PLACEHOLDER, { sort: true });
62
+
63
+ /** `text` with each `{name}` replaced by vars[name] (an unknown name is left as written). */
64
+ export const fill = (text, vars = {}) => String(text).replace(PLACEHOLDER, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
65
+
66
+ /** The message `en` in `language` (`vi` translates through the catalog; any other language is the English source). */
67
+ export function translate(en, vars = {}, { language = DEFAULT_OWNER_LANGUAGE, root = skillRoot, scope = 'runtime' } = {}) {
68
+ const vi = language === 'vi' ? loadCatalog(root, { scope }).get(en) : undefined;
69
+ return fill(vi ?? en, vars);
70
+ }
71
+
72
+ /** The owner's language (config.yaml `language`); `fallback` when there is no readable config (DEFAULT_OWNER_LANGUAGE of engine/config.mjs). */
73
+ export function ownerLanguage(fallback = DEFAULT_OWNER_LANGUAGE) {
74
+ try { return loadConfig()?.language ?? fallback; } catch { return fallback; }
75
+ }
76
+
77
+ /** A translator bound to one language: `tr(en, vars)`. */
78
+ export const translator = (language = DEFAULT_OWNER_LANGUAGE, { root = skillRoot, scope = 'runtime' } = {}) =>
79
+ (en, vars = {}) => translate(en, vars, { language, root, scope });
80
+
81
+ /**
82
+ * A pattern source matching `en` in the owner's language or in English: each language's rendering of the English source,
83
+ * escaped, with the placeholder `slot` (a placeholder name of `en`) replaced by `slotPattern`. Never hardcodes a spelling.
84
+ */
85
+ export const translatedPattern = (en, slot, slotPattern, languages = ['en', 'vi']) =>
86
+ `(?:${languages.map((language) => escapeRegExp(translate(en, { [slot]: '@@' }, { language })).replace('@@', slotPattern)).join('|')})`;
@@ -1,7 +1,7 @@
1
1
  // json.mjs — the ledger's forgiving JSON read. Every one of these rows may hold text a peer wrote or a
2
2
  // truncated payload, so a malformed column is the caller's fallback, never a throw. The parse of the
3
3
  // literal 'null' is kept as null (callers that want {} instead write `parseJsonOr`).
4
- import fs from 'node:fs';
4
+ import { readParsedFile } from './read-text.mjs';
5
5
 
6
6
  /** `text` as JSON, or `fallback` when it does not parse. */
7
7
  export const parseJson = (text, fallback = null) => {
@@ -17,6 +17,16 @@ export const parseJsonOr = (text, fallback = {}) => parseJson(text) ?? fallback;
17
17
  export const withPayload = (row, fallback = {}) => (row ? { ...row, payload: parseJsonOr(row.payload_json, fallback) } : row);
18
18
 
19
19
  /** `file` read and parsed as JSON, or `fallback` when the file is missing, unreadable or malformed. */
20
- export const readJsonFile = (file, fallback = null) => {
21
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
20
+ export const readJsonFile = (file, fallback = null) => readParsedFile(file, JSON.parse, fallback);
21
+
22
+ /** `stdout` as JSON: the trimmed text, else its first-{-to-last-} span; null when neither parses. */
23
+ export const jsonFromStdout = (stdout) => {
24
+ const text = String(stdout ?? '').trim();
25
+ if (!text) return null;
26
+ try { return JSON.parse(text); } catch { /* fall through */ }
27
+ const first = text.indexOf('{'), last = text.lastIndexOf('}');
28
+ if (first >= 0 && last > first) {
29
+ try { return JSON.parse(text.slice(first, last + 1)); } catch { /* not json */ }
30
+ }
31
+ return null;
22
32
  };
@@ -39,6 +39,8 @@ export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meanin
39
39
  * - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
40
40
  * - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
41
41
  * phrase lists of the archetype recognisers).
42
+ * - modules/goal/source-phrases.yaml: the Vietnamese phrase lists runtime source matchers read through
43
+ * scripts/lib/source-phrases.mjs (owner replies, report wording, product copy) - each leaf list under `phrases`.
42
44
  * An op manifest (modules/ops/ops/<id>.yaml, the starci/op@1 documents check-op-manifest types) is declared by
43
45
  * OP_MANIFEST_VIETNAMESE_FIELDS: its text objects are {en, vi} (modules/schemas/op.schema.yaml $defs text), and `vi` is the
44
46
  * owner's reading of the same rule.
@@ -52,13 +54,30 @@ export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
52
54
  'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
53
55
  ]),
54
56
  }),
57
+ 'modules/goal/source-phrases.yaml': Object.freeze({
58
+ fields: Object.freeze([
59
+ 'prefer', 'avoid', 'negation', 'refused', 'accept', 'golden',
60
+ 'missingPaths', 'grantTooNarrow', 'toolTimeout', 'testGap', 'checkerUnavailable',
61
+ 'supervisorVerbs', 'ownerWord', 'orWord', 'beyondAuthority', 'ownerOnly', 'workerDied', 'contractConflict', 'decision',
62
+ 'product', 'grammar', 'knowledge', 'sourceLabel', 'internalVocabulary', 'actor', 'success',
63
+ 'wizard', 'form', 'dashboard', 'gap', 'anatomy', 'otpLabel',
64
+ ]),
65
+ }),
55
66
  });
56
67
 
68
+ /**
69
+ * The Vietnamese catalog of owner-visible messages (scripts/lib/i18n.mjs): modules/i18n/messages/<area>.yaml, a list of
70
+ * `{en, vi}` entries keyed from the English source. The `vi` field (block or inline flow) is the declared Vietnamese.
71
+ */
72
+ const I18N_CATALOG_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']), flowFields: Object.freeze(['vi']) });
73
+ const I18N_CATALOG_DIR = 'modules/i18n/messages/';
74
+
57
75
  /** The `vi` field of an op manifest's text objects (modules/schemas/op.schema.yaml $defs text). */
58
- export const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
76
+ const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
59
77
  const OP_MANIFEST_DIR = 'modules/ops/ops/';
60
78
  /** The declared Vietnamese fields of `rel`: its own entry, or the op-manifest text field for a manifest of modules/ops/ops. */
61
79
  export const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
80
+ ?? (rel.startsWith(I18N_CATALOG_DIR) && !rel.slice(I18N_CATALOG_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? I18N_CATALOG_VIETNAMESE_FIELDS : null)
62
81
  ?? (rel.startsWith(OP_MANIFEST_DIR) && !rel.slice(OP_MANIFEST_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? OP_MANIFEST_VIETNAMESE_FIELDS : null);
63
82
 
64
83
  /**
@@ -66,7 +85,7 @@ export const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[re
66
85
  * fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
67
86
  * comment pragma and no path pattern. The slots are declared in knowledge/hfs/slots.yaml.
68
87
  */
69
- export const LOCALIZED_TEXT_SLOTS = Object.freeze([
88
+ const LOCALIZED_TEXT_SLOTS = Object.freeze([
70
89
  'be.domain.messages', 'be.feature.messages', 'be.tests.fixtures.i18n',
71
90
  'fe.modules.i18n', 'fe.package.i18n',
72
91
  ]);
@@ -78,7 +97,7 @@ export const isLocalizedDataFile = (rel, slot) => LOCALIZED_TEXT_SLOTS.includes(
78
97
  * The key each line of a YAML text belongs to: a `key:` line owns itself, and every deeper-indented line after it (a list item,
79
98
  * a folded block, a wrapped scalar) belongs to the same key. A line at or above the key's indent that is not a key belongs to none.
80
99
  */
81
- export function yamlKeyOfEachLine(text) {
100
+ function yamlKeyOfEachLine(text) {
82
101
  let key = null;
83
102
  let keyIndent = -1;
84
103
  return String(text).split(/\r?\n/).map((raw) => {
@@ -105,13 +124,13 @@ export function documentLanguageHits(rel, text) {
105
124
  if (declared.fields?.includes(keys[hit.line - 1])) return false;
106
125
  if (!declared.flowFields) return true;
107
126
  // an inline flow map: drop the declared fields' values and judge what is left of the line
108
- const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*[^,}]*`, 'g'), ''), lines[hit.line - 1]);
127
+ const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*(?:"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*'|[^,}]*)`, 'g'), ''), lines[hit.line - 1]);
109
128
  return hasSecondLanguage(rest);
110
129
  });
111
130
  }
112
131
 
113
132
  /** The file extensions of a prose document (Markdown and YAML). */
114
- export const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
133
+ const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
115
134
 
116
135
  /** Whether `rel` is a document a language check reads. */
117
136
  export const isDocument = (rel) => DOCUMENT_EXTENSIONS.includes(path.posix.extname(rel));
@@ -0,0 +1,20 @@
1
+ // list.mjs — the array-or-empty coercion half the scripts re-declared by hand.
2
+ // `v` when it already is an array, else []. Scalars are NOT wrapped: a caller that wants
3
+ // `[v]` for a lone value writes its own coercion (see contract-version's list).
4
+ export const list = (v) => (Array.isArray(v) ? v : []);
5
+
6
+ /** `v` as an array: itself when already one, [] for null/undefined, else [v]. */
7
+ export const asList = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
8
+
9
+ /** The object entries of `v` (a non-array reads as []). */
10
+ export const objectList = (v) => (Array.isArray(v) ? v : []).filter((item) => item && typeof item === 'object');
11
+
12
+ /** Every entry of `v` a non-empty string (`blank: false` also rejects all-whitespace entries). */
13
+ export const stringList = (v, { blank = true } = {}) =>
14
+ Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0 && (blank || s.trim().length > 0));
15
+
16
+ /** The items of a `sep`-separated list: trimmed, empties dropped; `dedupe` keeps first occurrences only. */
17
+ export const splitList = (v, { sep = ',', dedupe = false } = {}) => {
18
+ const items = String(v ?? '').split(sep).map((s) => s.trim()).filter(Boolean);
19
+ return dedupe ? [...new Set(items)] : items;
20
+ };
@@ -1,4 +1,5 @@
1
1
  // path-key.mjs — spelling and comparing paths the way this host's filesystem does.
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
4
5
  const WIN = process.platform === 'win32';
@@ -13,7 +14,61 @@ export const sameOrUnder = (file, prefix) => file === prefix || file.startsWith(
13
14
  export const foldCase = (p) => (WIN ? p.toLowerCase() : p);
14
15
  /** Whether two spellings name the same path on this host's filesystem. */
15
16
  export const samePath = (a, b) => foldCase(a) === foldCase(b);
16
- /** A path's comparable identity: absolute, forward slashes, no trailing slash, case-folded on Windows. */
17
- export const pathKey = (p) => foldCase(slash(path.resolve(p)).replace(/\/+$/, ''));
17
+ /**
18
+ * A path's comparable identity: absolute, forward slashes, no trailing slash, case-folded on Windows.
19
+ * `fold: true` folds case on every platform (a comparison against a lowercase-stored root needs it).
20
+ */
21
+ export const pathKey = (p, { fold = WIN } = {}) => {
22
+ const key = slash(path.resolve(p)).replace(/\/+$/, '');
23
+ return fold ? key.toLowerCase() : key;
24
+ };
25
+ /** `a` is `b` or under it, or the reverse: two path spellings whose trees touch. */
26
+ export const pathsOverlap = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a);
27
+ /**
28
+ * A path-ish string in compare form, options in spelling order:
29
+ * `trim` blanks at the ends, `collapse` folds '//' runs to '/', `dot` drops a leading './',
30
+ * `glob` strips a trailing glob ('star': '*'/'**' with their slash, 'double': a '/**' only),
31
+ * `fold` lowercases. A trailing '/' always goes. Null reads as ''.
32
+ */
33
+ export const normPath = (p, { trim = false, collapse = false, dot = false, glob = null, fold = false } = {}) => {
34
+ let s = String(p ?? '');
35
+ if (trim) s = s.trim();
36
+ s = slash(s);
37
+ if (collapse) s = s.replace(/\/{2,}/g, '/');
38
+ if (dot) s = s.replace(/^\.\//, '');
39
+ if (glob === 'star') s = s.replace(/\/?\*+$/, '');
40
+ else if (glob === 'double') s = s.replace(/\/\*\*$/, '');
41
+ s = s.replace(/\/+$/, '');
42
+ return fold ? s.toLowerCase() : s;
43
+ };
18
44
  /** A work-relative path in one spelling: forward slashes, no leading ./, no trailing slash. */
19
45
  export const normWork = (p) => String(p ?? '').trim().replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
46
+
47
+ /** `p` resolved and dereferenced (realpath where the path exists) — the canonical spelling of a checkout dir. */
48
+ export const realPath = (p) => {
49
+ const resolved = path.resolve(p);
50
+ try { return fs.realpathSync.native(resolved); } catch { return resolved; }
51
+ };
52
+ /** `p` resolved and case-folded where the filesystem ignores case — the Map key of an absolute path. */
53
+ export const resolvedKey = (p) => foldCase(path.resolve(p));
54
+ /** Whether `a` and `b` resolve to the same path (always case-folded, so differently-cased spellings count). */
55
+ export const sameResolvedPath = (a, b) => pathKey(a, { fold: true }) === pathKey(b, { fold: true });
56
+ /**
57
+ * Whether `file` sits strictly under `root`. `key` normalizes both sides first (e.g. the registry's
58
+ * realpath-folded treeKey) when the callers' spellings need it.
59
+ */
60
+ export const insidePath = (root, file, { key = (p) => p } = {}) => {
61
+ const rel = path.relative(key(root), key(file));
62
+ return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
63
+ };
64
+ /**
65
+ * A path-ish value (a string or a {path} entry) in relative compare form: trimmed, forward slashes,
66
+ * no trailing '/**' glob or slashes, no leading './'; `fold` lowercases for a case-insensitive key.
67
+ */
68
+ export const normRel = (p, { fold = false } = {}) => {
69
+ const s = String(typeof p === 'string' ? p : p?.path ?? '').trim().replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/+$/, '').replace(/^\.\//, '');
70
+ return fold ? s.toLowerCase() : s;
71
+ };
72
+ /** Whether `file` equals or sits under one of `prefixes` (posix spellings; `dot` counts a '.' prefix as covering all). */
73
+ export const underAny = (file, prefixes, { dot = false } = {}) =>
74
+ (prefixes ?? []).some((p) => (dot && p === '.') || sameOrUnder(file, p));
@@ -0,0 +1,34 @@
1
+ // read-text.mjs — the forgiving text read: a missing or unreadable file is the caller's fallback,
2
+ // never a throw (the JSON twin is ./json.mjs readJsonFile, the YAML twin ./read-yaml.mjs).
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ /** The text of `rel` under `root` ('/'-separated rel), or null when it is missing or unreadable. */
7
+ export const readTextFile = (root, rel) => {
8
+ try { return fs.readFileSync(path.join(root, ...String(rel).split('/')), 'utf8'); } catch { return null; }
9
+ };
10
+
11
+ /** `file` read and parsed by `parse`, or `fallback` when it is missing, unreadable or malformed. */
12
+ export const readParsedFile = (file, parse, fallback = null) => {
13
+ try { return parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
14
+ };
15
+
16
+ /**
17
+ * Feed `file`'s lines to `onLine` reading in bounded chunks — a diff or a patch can exceed any single
18
+ * string. `eol` is the line-ending pattern or literal (default CRLF-aware); a last unterminated line is fed too.
19
+ */
20
+ export function forEachFileLine(file, onLine, { chunkBytes = 8 * 1024 * 1024, eol = /\r?\n/ } = {}) {
21
+ const fd = fs.openSync(file, 'r');
22
+ try {
23
+ const buf = Buffer.alloc(chunkBytes);
24
+ let carry = '';
25
+ for (;;) {
26
+ const n = fs.readSync(fd, buf, 0, chunkBytes, null);
27
+ if (n <= 0) break;
28
+ const lines = (carry + buf.toString('utf8', 0, n)).split(eol);
29
+ carry = lines.pop();
30
+ for (const l of lines) onLine(l);
31
+ }
32
+ if (carry) onLine(carry);
33
+ } finally { fs.closeSync(fd); }
34
+ }
@@ -0,0 +1,4 @@
1
+ // regex.mjs - building a RegExp from text.
2
+
3
+ /** `text` with every regular-expression metacharacter escaped, so it matches itself literally. */
4
+ export const escapeRegExp = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -23,11 +23,12 @@ export const SECRET_PATTERNS = [
23
23
  { name: 'telegram-bot-token', re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/ },
24
24
  { name: 'stripe-secret', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
25
25
  { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
26
+ // A value written `env(NAME)` (the Supabase config.toml reference form) names a variable, not a secret.
26
27
  // A keyword-assigned value that names itself a stand-in (a test stub's `accessToken: "fixture-..."`) is no
27
28
  // candidate; only this heuristic takes the exemption, never a provider-shaped token above.
28
- // A spec file's keyword-assigned values are test inputs (nivo-fe AuthenticationPage/index.spec.tsx: the sign-in
29
- // form's password and a mocked accessToken refused the push, 2026-09-24): the heuristic skips spec files too.
30
- { name: 'assigned-secret', re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]([^'"\s$<{]{12,})['"]/i, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
29
+ // A spec file's keyword-assigned values are test inputs (a spec's sign-in
30
+ // form password and a mocked accessToken once refused a push): the heuristic skips spec files too.
31
+ { name: 'assigned-secret', re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"](?!env\([A-Z_][A-Z0-9_]*\)['"])([^'"\s$<{]{12,})['"]/i, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
31
32
  skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
32
33
  ];
33
34
 
@@ -0,0 +1,58 @@
1
+ // sops-envelope.mjs — the pure rules of a test credential (owner ruling push-scan-test-secrets-encrypted, 2026-09-28):
2
+ // where it lives in a product repository's `.starcistacks`, the command that stores it, the sops format of its path, and
3
+ // whether a committed file is a sops envelope with no plaintext value (the push secret scan's test). Reading one is
4
+ // scripts/uat/test-secret.mjs; the sops binary is found by scripts/api/sops/lib.mjs.
5
+ import path from 'node:path';
6
+ import { parseYaml } from '../../engine/yaml.mjs';
7
+
8
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
9
+ const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
10
+ /** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
11
+ const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
12
+
13
+ /** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
14
+ * `stack-secret.mjs set` takes. */
15
+ export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
16
+ if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
17
+ if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
18
+ if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
19
+ const rel = `${stack}/secrets/test/${name}`;
20
+ const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
21
+ return { plain, enc: `${plain}.enc`, rel };
22
+ }
23
+
24
+ /** The command that stores a test credential, in the product repository's own tooling. */
25
+ export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
26
+
27
+ /** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
28
+ export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
29
+
30
+ /** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
31
+ const allEncrypted = (node) => {
32
+ if (node === null) return true;
33
+ if (Array.isArray(node)) return node.every(allEncrypted);
34
+ if (typeof node === 'object') return Object.values(node).every(allEncrypted);
35
+ return typeof node === 'string' && SOPS_VALUE.test(node);
36
+ };
37
+ const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
38
+ && typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
39
+ && Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
40
+
41
+ /**
42
+ * True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
43
+ * yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
44
+ * The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
45
+ */
46
+ export function isSopsEnvelope(text) {
47
+ const src = String(text ?? '').replace(/^/, '');
48
+ if (!src.trim()) return false;
49
+ try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
50
+ const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
51
+ if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
52
+ const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
53
+ const meta = pairs.filter(([k]) => k.startsWith('sops_'));
54
+ const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
55
+ return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
56
+ }
57
+ try { return sopsTree(parseYaml(src)); } catch { return false; }
58
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from '../../engine/yaml.mjs';
4
+ import { words } from './dockerfile.mjs';
4
5
 
5
6
  /**
6
7
  * The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
@@ -14,11 +15,11 @@ import { parseYaml } from '../../engine/yaml.mjs';
14
15
  * service and is returned as `proxy`.
15
16
  */
16
17
  export const STACKS_DIRECTORY = '.starcistacks';
17
- export const STACKS_FILE = 'application-stacks.yaml';
18
+ const STACKS_FILE = 'application-stacks.yaml';
18
19
  export const DEFAULT_ENVIRONMENT = 'dev';
19
20
 
20
21
  /** Test-only value the `*_FILE` secret variables of a compose service become (the file variable is replaced by the plain variable of the same name). Never a real secret: the test stack listens on loopback only. */
21
- export const TEST_SECRET_VALUE = 'starci-test-secret';
22
+ const TEST_SECRET_VALUE = 'starci-test-secret';
22
23
 
23
24
  const NOT_RUN_ROLES = new Set(['service', 'observability']);
24
25
  const NOT_RUN_PROFILES = new Set(['app']);
@@ -44,8 +45,6 @@ const KNOWN_IMAGES = Object.freeze({
44
45
  clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
45
46
  kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
46
47
  redpanda: { kind: 'queue', aliases: ['redpanda', 'kafka', 'queue', 'broker'], probe: { tcp: true } },
47
- rabbitmq: { kind: 'queue', aliases: ['rabbitmq', 'amqp', 'queue', 'broker'], probe: { tcp: true } },
48
- nats: { kind: 'queue', aliases: ['nats', 'queue', 'broker'], probe: { tcp: true } },
49
48
  elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
50
49
  opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
51
50
  });
@@ -58,14 +57,14 @@ const KNOWN_IMAGES = Object.freeze({
58
57
  export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
59
58
 
60
59
  /** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
61
- export const repositoryOf = image => {
60
+ const repositoryOf = image => {
62
61
  const withoutDigest = image.split('@')[0];
63
62
  const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
64
63
  return lastSegment.split(':')[0];
65
64
  };
66
65
 
67
66
  /** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
68
- export const imagePathOf = image => {
67
+ const imagePathOf = image => {
69
68
  const withoutDigest = image.split('@')[0];
70
69
  const lastSlash = withoutDigest.lastIndexOf('/');
71
70
  const colon = withoutDigest.indexOf(':', lastSlash + 1);
@@ -73,30 +72,7 @@ export const imagePathOf = image => {
73
72
  };
74
73
 
75
74
  /** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
76
- export const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
77
-
78
- /** The shell-like words of a compose `command` string, double and single quotes grouping a word. */
79
- const words = text => {
80
- const out = [];
81
- let current = '';
82
- let quote = null;
83
- let started = false;
84
- for (const char of text) {
85
- if (quote) {
86
- if (char === quote) quote = null;
87
- else current += char;
88
- } else if (char === '"' || char === "'") {
89
- quote = char;
90
- started = true;
91
- } else if (/\s/u.test(char)) {
92
- if (started || current) out.push(current);
93
- current = '';
94
- started = false;
95
- } else current += char;
96
- }
97
- if (started || current) out.push(current);
98
- return out;
99
- };
75
+ const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
100
76
 
101
77
  const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
102
78
 
@@ -129,7 +105,7 @@ const mountsOf = (value, directory) => {
129
105
  continue;
130
106
  }
131
107
  const parts = String(entry).split(':');
132
- // A Windows drive letter is not a separator: `C:\x:/y` keeps its drive.
108
+ // A Windows drive letter is not a separator: a `<drive>:<path>:/y` mount keeps its drive.
133
109
  const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
134
110
  const target = parts.shift();
135
111
  if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
@@ -0,0 +1,35 @@
1
+ // ts-ast.mjs — the small reads the hfs checks share over TypeScript's own AST: a node's name or
2
+ // literal text, a node's 1-based position, a named property's initializer text. `ts` is the caller's
3
+ // already-resolved compiler (see package-at.mjs loadTypescript); nothing here resolves a package.
4
+
5
+ /** The text of an identifier or a string-literal-like `node`, or null (property names, literal specifiers). */
6
+ export const nameText = (ts, node) => (node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) ? node.text : null);
7
+
8
+ /** The text of a string-literal-like `node` (a string literal or a no-substitution template), or null. */
9
+ export const literalText = (ts, node) => (node && ts.isStringLiteralLike(node) ? node.text : null);
10
+
11
+ /** The 1-based {line, column} of `node` in `sourceFile`. */
12
+ export const sourceLocation = (sourceFile, node) => {
13
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
14
+ return { line: position.line + 1, column: position.character + 1 };
15
+ };
16
+
17
+ /**
18
+ * The `.text` of `key`'s property initializer in `text` (parsed as `file`): a property assignment
19
+ * inside an object literal, or — `declaration: true` — a class property declaration. `kind` is
20
+ * 'string' (a string-literal-like initializer) or 'identifier'. The last match wins; null when none.
21
+ */
22
+ export function propertyText(ts, text, { file = 'source.ts', key, declaration = false, kind = 'string' } = {}) {
23
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
24
+ let value = null;
25
+ const visit = (node) => {
26
+ const isProp = declaration ? ts.isPropertyDeclaration(node) : ts.isPropertyAssignment(node);
27
+ if (isProp && node.name && ts.isIdentifier(node.name) && node.name.text === key && node.initializer) {
28
+ const ok = kind === 'identifier' ? ts.isIdentifier(node.initializer) : ts.isStringLiteralLike(node.initializer);
29
+ if (ok) value = node.initializer.text;
30
+ }
31
+ ts.forEachChild(node, visit);
32
+ };
33
+ visit(source);
34
+ return value;
35
+ }
@@ -2,10 +2,11 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). */
6
- export function isInside(root, target) {
5
+ /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). `includeSelf: false` excludes `target === root`. */
6
+ export function isInside(root, target, { includeSelf = true } = {}) {
7
7
  const relative = path.relative(root, target);
8
- return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
8
+ if (relative === '') return includeSelf;
9
+ return !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
9
10
  }
10
11
  /**
11
12
  * Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full