codictator 0.1.2 → 0.2.0

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 (71) hide show
  1. package/dist/builtin-packs/core/fixtures/conventional-commit/passing/basic/case.json +1 -1
  2. package/dist/builtin-packs/core/fixtures/conventional-commit/violating/basic/case.json +1 -1
  3. package/dist/builtin-packs/typescript/fixtures/lint/passing/exported/case.json +8 -0
  4. package/dist/builtin-packs/typescript/pack.json +1 -1
  5. package/dist/cli/commands.d.ts +1 -1
  6. package/dist/cli/commands.js +3 -1
  7. package/dist/cli/gate-scope.d.ts +1 -0
  8. package/dist/cli/gate-scope.js +11 -0
  9. package/dist/cli/git-hook.js +13 -8
  10. package/dist/cli/guardrail.d.ts +2 -0
  11. package/dist/cli/guardrail.js +11 -13
  12. package/dist/cli/init-plan.d.ts +7 -0
  13. package/dist/cli/init-plan.js +21 -2
  14. package/dist/cli/init.d.ts +2 -0
  15. package/dist/cli/init.js +8 -17
  16. package/dist/cli/plan-launcher.d.ts +1 -0
  17. package/dist/cli/plan-launcher.js +53 -3
  18. package/dist/cli/serve-run.js +1 -0
  19. package/dist/cli/setup-gitignore.d.ts +7 -0
  20. package/dist/cli/setup-gitignore.js +41 -0
  21. package/dist/cli/setup-handoff.d.ts +7 -0
  22. package/dist/cli/setup-handoff.js +44 -0
  23. package/dist/cli/setup-install.d.ts +11 -0
  24. package/dist/cli/setup-install.js +84 -0
  25. package/dist/cli/setup.d.ts +9 -0
  26. package/dist/cli/setup.js +143 -0
  27. package/dist/cli/task-advance.js +16 -16
  28. package/dist/features/adapters/claude-shaped.d.ts +1 -0
  29. package/dist/features/adapters/claude-shaped.js +29 -31
  30. package/dist/features/adapters/copilot.js +22 -29
  31. package/dist/features/audit/append.js +9 -4
  32. package/dist/features/audit/surfaces.js +15 -8
  33. package/dist/features/ci/protection.js +37 -13
  34. package/dist/features/deps/npm.js +39 -24
  35. package/dist/features/deps/registry.js +45 -34
  36. package/dist/features/deps/stable.js +52 -27
  37. package/dist/features/deps/upgrade.d.ts +1 -1
  38. package/dist/features/deps/upgrade.js +29 -19
  39. package/dist/features/engine/evaluate.js +6 -1
  40. package/dist/features/packs/enforcers/ci-guards.js +4 -1
  41. package/dist/features/packs/enforcers/code.js +2 -1
  42. package/dist/features/packs/enforcers/engine-version-guards.d.ts +6 -0
  43. package/dist/features/packs/enforcers/engine-version-guards.js +42 -0
  44. package/dist/features/packs/enforcers/external-bind.js +62 -28
  45. package/dist/features/packs/enforcers/external-sarif.js +30 -24
  46. package/dist/features/packs/enforcers/gate-guards.d.ts +0 -1
  47. package/dist/features/packs/enforcers/gate-guards.js +83 -136
  48. package/dist/features/packs/enforcers/health-measures.d.ts +2 -0
  49. package/dist/features/packs/enforcers/health-measures.js +10 -1
  50. package/dist/features/packs/enforcers/health-refs.d.ts +1 -2
  51. package/dist/features/packs/enforcers/health-refs.js +71 -56
  52. package/dist/features/packs/enforcers/secret-scan.d.ts +4 -20
  53. package/dist/features/packs/enforcers/secret-scan.js +61 -67
  54. package/dist/features/packs/enforcers/ts-checks.js +104 -58
  55. package/dist/features/packs/enforcers/ts-security-patterns.js +12 -8
  56. package/dist/features/packs/manifest-decl.d.ts +13 -0
  57. package/dist/features/packs/manifest-decl.js +121 -0
  58. package/dist/features/packs/manifest.d.ts +2 -12
  59. package/dist/features/packs/manifest.js +5 -90
  60. package/dist/features/packs/selftest.js +22 -5
  61. package/dist/features/remediation/adoption.js +40 -29
  62. package/dist/features/testing/scenarios.js +49 -27
  63. package/dist/features/testing/tap-parser.js +38 -26
  64. package/dist/features/workflow/phases.js +45 -42
  65. package/dist/features/workflow/review.js +17 -10
  66. package/dist/kernel/git.js +4 -1
  67. package/dist/kernel/pattern.js +120 -86
  68. package/dist/kernel/secret-paths.js +34 -20
  69. package/dist/kernel/shell-cmd.js +23 -6
  70. package/dist/tsconfig.build.tsbuildinfo +1 -1
  71. package/package.json +1 -1
@@ -42,6 +42,54 @@ function reachableModules(test, importMap) {
42
42
  }
43
43
  return reached;
44
44
  }
45
+ function coversChangedModule(input, globs, test, changed) {
46
+ if (!globs.some((glob) => globMatch(glob, test.path))) {
47
+ return false;
48
+ }
49
+ const reached = reachableModules(test, input.importMap);
50
+ return [...changed].some((module) => reached.has(module));
51
+ }
52
+ function coveringTests(input, kind, changed) {
53
+ const globs = input.kindGlobs[kind] ?? [];
54
+ return input.changedTests.filter((test) => coversChangedModule(input, globs, test, changed));
55
+ }
56
+ function architectureFinding(input, changed) {
57
+ const unmapped = [...changed].filter((module) => !input.mappedModules.includes(module));
58
+ if (unmapped.length === 0) {
59
+ return undefined;
60
+ }
61
+ return kindFinding(`architecture test missing: ${unmapped.join(", ")} map to no architecture module`, `Map ${unmapped.join(", ")} in config.architecture so the arch guard covers them.`);
62
+ }
63
+ function missingKindFinding(input, kind, changed) {
64
+ const globs = input.kindGlobs[kind] ?? [];
65
+ return kindFinding(`${kind} test missing for changed ${[...changed].join(", ") || "files"}`, `Add a ${kind} test under ${globs.join(", ") || "its kind globs"} importing a changed module.`);
66
+ }
67
+ function budgetFindings(kind, covering, budgets) {
68
+ const findings = [];
69
+ for (const test of covering) {
70
+ if (budgets[test.path] !== true) {
71
+ findings.push(kindFinding(`${kind} test without a budget line: ${test.path}`, `Declare a codictator:budget line (p95, p99, rps or maxMs) in ${test.path}.`));
72
+ }
73
+ }
74
+ return findings;
75
+ }
76
+ function collectKindFindings(input, kind, changed, findings) {
77
+ if (kind === "architecture") {
78
+ const missing = architectureFinding(input, changed);
79
+ if (missing !== undefined) {
80
+ findings.push(missing);
81
+ }
82
+ return;
83
+ }
84
+ const covering = coveringTests(input, kind, changed);
85
+ if (covering.length === 0) {
86
+ findings.push(missingKindFinding(input, kind, changed));
87
+ return;
88
+ }
89
+ if (kind === "stress" || kind === "load") {
90
+ findings.push(...budgetFindings(kind, covering, input.budgets));
91
+ }
92
+ }
45
93
  // A kind is satisfied by a changed test file under its kind globs that
46
94
  // imports (transitively, depth 2) a changed module. Architecture is
47
95
  // satisfied when config.architecture maps every changed module, because
@@ -51,33 +99,7 @@ export function checkTestKinds(input) {
51
99
  const findings = [];
52
100
  const changed = new Set(input.changedModules);
53
101
  for (const kind of input.required) {
54
- if (kind === "architecture") {
55
- const unmapped = [...changed].filter((module) => !input.mappedModules.includes(module));
56
- if (unmapped.length === 0) {
57
- continue;
58
- }
59
- findings.push(kindFinding(`architecture test missing: ${unmapped.join(", ")} map to no architecture module`, `Map ${unmapped.join(", ")} in config.architecture so the arch guard covers them.`));
60
- continue;
61
- }
62
- const globs = input.kindGlobs[kind] ?? [];
63
- const covering = input.changedTests.filter((test) => {
64
- if (!globs.some((glob) => globMatch(glob, test.path))) {
65
- return false;
66
- }
67
- const reached = reachableModules(test, input.importMap);
68
- return [...changed].some((module) => reached.has(module));
69
- });
70
- if (covering.length === 0) {
71
- findings.push(kindFinding(`${kind} test missing for changed ${[...changed].join(", ") || "files"}`, `Add a ${kind} test under ${globs.join(", ") || "its kind globs"} importing a changed module.`));
72
- continue;
73
- }
74
- if (kind === "stress" || kind === "load") {
75
- for (const test of covering) {
76
- if (input.budgets[test.path] !== true) {
77
- findings.push(kindFinding(`${kind} test without a budget line: ${test.path}`, `Declare a codictator:budget line (p95, p99, rps or maxMs) in ${test.path}.`));
78
- }
79
- }
80
- }
102
+ collectKindFindings(input, kind, changed, findings);
81
103
  }
82
104
  return findings;
83
105
  }
@@ -48,33 +48,36 @@ function recordResult(stack, indent, name, passed, outcomes) {
48
48
  });
49
49
  return false;
50
50
  }
51
- export function parseTap(tap, filePath) {
52
- const stack = [];
53
- const outcomes = [];
54
- for (const rawLine of tap.split("\n")) {
55
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
56
- const trimmed = line.trimStart();
57
- if (trimmed.startsWith("# Subtest: ")) {
58
- openChild(stack);
59
- stack.push({
60
- name: unescapeTapName(trimmed.slice("# Subtest: ".length).trimEnd()),
61
- indent: countIndent(line),
62
- hasChildren: false,
63
- });
64
- continue;
65
- }
66
- if (trimmed.startsWith("#")) {
67
- continue;
68
- }
69
- const match = /^( *)(not )?ok [0-9]+ - (.*)$/.exec(line);
70
- if (match === null) {
71
- continue;
72
- }
73
- const indent = (match[1] ?? "").length;
74
- const passed = match[2] === undefined;
75
- const name = unescapeTapName((match[3] ?? "").trimEnd());
76
- recordResult(stack, indent, name, passed, outcomes);
51
+ // The result pattern lives at module scope so its quantifiers never count
52
+ // as branches of the function that applies it.
53
+ const TAP_RESULT_PATTERN = /^( *)(not )?ok [0-9]+ - (.*)$/;
54
+ function recordTapResult(stack, outcomes, line) {
55
+ const match = TAP_RESULT_PATTERN.exec(line);
56
+ if (match === null) {
57
+ return;
58
+ }
59
+ const indent = (match[1] ?? "").length;
60
+ const passed = match[2] === undefined;
61
+ const name = unescapeTapName((match[3] ?? "").trimEnd());
62
+ recordResult(stack, indent, name, passed, outcomes);
63
+ }
64
+ function recordTapLine(stack, outcomes, line) {
65
+ const trimmed = line.trimStart();
66
+ if (trimmed.startsWith("# Subtest: ")) {
67
+ openChild(stack);
68
+ stack.push({
69
+ name: unescapeTapName(trimmed.slice("# Subtest: ".length).trimEnd()),
70
+ indent: countIndent(line),
71
+ hasChildren: false,
72
+ });
73
+ return;
74
+ }
75
+ if (trimmed.startsWith("#")) {
76
+ return;
77
77
  }
78
+ recordTapResult(stack, outcomes, line);
79
+ }
80
+ function finishTapFile(outcomes, filePath) {
78
81
  const leaves = outcomes.filter((outcome) => outcome.title !== filePath);
79
82
  const fileLevelFailed = outcomes.some((outcome) => outcome.title === filePath && !outcome.passed);
80
83
  return {
@@ -82,3 +85,12 @@ export function parseTap(tap, filePath) {
82
85
  loadFailed: fileLevelFailed && leaves.length === 0,
83
86
  };
84
87
  }
88
+ export function parseTap(tap, filePath) {
89
+ const stack = [];
90
+ const outcomes = [];
91
+ for (const rawLine of tap.split("\n")) {
92
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
93
+ recordTapLine(stack, outcomes, line);
94
+ }
95
+ return finishTapFile(outcomes, filePath);
96
+ }
@@ -58,53 +58,56 @@ function e606TwoTasks(open, task) {
58
58
  }
59
59
  // SPEC §4.12.2 exit gates: the evidence each phase owes before the next
60
60
  // phase opens. Implementation and integrate owe no exit evidence.
61
+ function missingDiscoveryEvidence(task) {
62
+ const missing = [];
63
+ if (task.spec.length === 0) {
64
+ missing.push("spec refs");
65
+ }
66
+ if (task.type === "fix" && (task.diagnosis ?? "").length === 0) {
67
+ missing.push("diagnosis");
68
+ }
69
+ return missing;
70
+ }
71
+ function missingPlanningEvidence(task) {
72
+ const missing = [];
73
+ if (task.files.allow.length === 0) {
74
+ missing.push("files.allow globs");
75
+ }
76
+ if (task.acceptanceCriteria.length === 0) {
77
+ missing.push("acceptance criteria");
78
+ }
79
+ if ((task.tests ?? []).length === 0) {
80
+ missing.push("declared tests");
81
+ }
82
+ if (task.redProof === undefined) {
83
+ missing.push("red proof");
84
+ }
85
+ return missing;
86
+ }
87
+ function missingRecordEvidence(task) {
88
+ const missing = [];
89
+ if (task.historyStaged !== true) {
90
+ missing.push("staged history entry");
91
+ }
92
+ if (task.type === "fix" && task.bugRowUpdated !== true) {
93
+ missing.push("bug row update");
94
+ }
95
+ return missing;
96
+ }
61
97
  function missingExitEvidence(task, phase) {
62
98
  switch (phase) {
63
- case "discovery": {
64
- const missing = [];
65
- if (task.spec.length === 0) {
66
- missing.push("spec refs");
67
- }
68
- if (task.type === "fix" && (task.diagnosis ?? "").length === 0) {
69
- missing.push("diagnosis");
70
- }
71
- return missing;
72
- }
73
- case "planning": {
74
- const missing = [];
75
- if (task.files.allow.length === 0) {
76
- missing.push("files.allow globs");
77
- }
78
- if (task.acceptanceCriteria.length === 0) {
79
- missing.push("acceptance criteria");
80
- }
81
- if ((task.tests ?? []).length === 0) {
82
- missing.push("declared tests");
83
- }
84
- if (task.redProof === undefined) {
85
- missing.push("red proof");
86
- }
87
- return missing;
88
- }
89
- case "validation": {
99
+ case "discovery":
100
+ return missingDiscoveryEvidence(task);
101
+ case "planning":
102
+ return missingPlanningEvidence(task);
103
+ case "validation":
90
104
  return task.greenProof === undefined ? ["green proof"] : [];
91
- }
92
- case "review": {
105
+ case "review":
93
106
  return missingReviewEvidence(task);
94
- }
95
- case "record": {
96
- const missing = [];
97
- if (task.historyStaged !== true) {
98
- missing.push("staged history entry");
99
- }
100
- if (task.type === "fix" && task.bugRowUpdated !== true) {
101
- missing.push("bug row update");
102
- }
103
- return missing;
104
- }
105
- default: {
107
+ case "record":
108
+ return missingRecordEvidence(task);
109
+ default:
106
110
  return [];
107
- }
108
111
  }
109
112
  }
110
113
  function missingReviewEvidence(task) {
@@ -1,20 +1,27 @@
1
1
  const SEVERITIES = ["blocker", "major", "minor", "nit"];
2
+ function invalidFindingReason(finding) {
3
+ if (typeof finding.id !== "string" ||
4
+ typeof finding.severity !== "string" ||
5
+ typeof finding.path !== "string" ||
6
+ typeof finding.message !== "string" ||
7
+ finding.id.length === 0 ||
8
+ finding.message.length === 0) {
9
+ return "a finding misses id, severity, path or message";
10
+ }
11
+ if (!SEVERITIES.includes(finding.severity)) {
12
+ return `severity "${finding.severity}" is not blocker, major, minor or nit`;
13
+ }
14
+ return undefined;
15
+ }
2
16
  function malformedReason(file) {
3
17
  if (!Array.isArray(file.findings)) {
4
18
  return "findings is not a list";
5
19
  }
6
20
  const seen = new Set();
7
21
  for (const finding of file.findings) {
8
- if (typeof finding.id !== "string" ||
9
- typeof finding.severity !== "string" ||
10
- typeof finding.path !== "string" ||
11
- typeof finding.message !== "string" ||
12
- finding.id.length === 0 ||
13
- finding.message.length === 0) {
14
- return "a finding misses id, severity, path or message";
15
- }
16
- if (!SEVERITIES.includes(finding.severity)) {
17
- return `severity "${finding.severity}" is not blocker, major, minor or nit`;
22
+ const reason = invalidFindingReason(finding);
23
+ if (reason !== undefined) {
24
+ return reason;
18
25
  }
19
26
  if (seen.has(finding.id)) {
20
27
  return `duplicate finding id "${finding.id}"`;
@@ -84,7 +84,10 @@ export class NodeGit {
84
84
  return ok(result.stdout);
85
85
  }
86
86
  async show(rev, path) {
87
- const result = await this.exec(["git", "show", `${rev}:${path}`]);
87
+ // Assembled off the call: an inline template inside exec( trips the
88
+ // exec-template pattern (SPEC §6.1), while argv entries never run a shell.
89
+ const revPath = `${rev}:${path}`;
90
+ const result = await this.exec(["git", "show", revPath]);
88
91
  const failure = procFailure("show", result);
89
92
  if (failure !== undefined) {
90
93
  return err(failure);
@@ -18,99 +18,133 @@ function backreferenceError() {
18
18
  function invalidError() {
19
19
  return err(makeError("CDX-E902", "pattern has unbalanced parentheses", "Balance every opening parenthesis before using the pattern."));
20
20
  }
21
- // SPEC §9.2: scan with escape and character-class awareness so an escaped
22
- // parenthesis or a bracketed quantifier cannot hide a nested quantifier.
23
- export function checkPattern(pattern) {
24
- if (pattern.length > MAX_PATTERN_LENGTH) {
25
- return tooLongError();
21
+ // The brace-quantifier pattern lives at module scope so its quantifiers
22
+ // never count as branches of the function that applies it.
23
+ const BRACE_QUANTIFIER_PATTERN = /^\{\d+(,\d*)?\}/;
24
+ function scanPatternEscape(pattern, state) {
25
+ const next = pattern.charAt(state.index + 1);
26
+ if (next !== "" && next >= "1" && next <= "9") {
27
+ return backreferenceError();
26
28
  }
27
- const groups = [];
28
- let quantifiedInside = false;
29
- let index = 0;
30
- while (index < pattern.length) {
31
- const current = pattern.charAt(index);
32
- if (current === "\\") {
33
- const next = pattern.charAt(index + 1);
34
- if (next !== "" && next >= "1" && next <= "9") {
35
- return backreferenceError();
36
- }
37
- if (next === "k") {
38
- return backreferenceError();
39
- }
40
- index += 2;
41
- continue;
42
- }
43
- if (current === "[") {
44
- index += 1;
45
- if (pattern.charAt(index) === "^") {
46
- index += 1;
47
- }
48
- if (pattern.charAt(index) === "]") {
49
- index += 1;
50
- }
51
- while (index < pattern.length && pattern.charAt(index) !== "]") {
52
- if (pattern.charAt(index) === "\\") {
53
- index += 1;
54
- }
55
- index += 1;
56
- }
57
- index += 1;
58
- continue;
59
- }
60
- if (current === "(") {
61
- groups.push({ quantifiedInside });
62
- quantifiedInside = false;
63
- index += 1;
64
- // SPEC §9.2: a ? directly after ( opens a non-capturing or
65
- // lookaround group; it quantifies nothing.
66
- if (pattern.charAt(index) === "?") {
67
- index += 1;
68
- const marker = pattern.charAt(index);
69
- if (marker === ":" || marker === "=" || marker === "!") {
70
- index += 1;
71
- }
72
- else if (marker === "<") {
73
- const close = pattern.indexOf(">", index);
74
- if (close >= 0) {
75
- index = close + 1;
76
- }
77
- }
78
- }
79
- continue;
80
- }
81
- if (current === ")") {
82
- const frame = groups.pop();
83
- if (frame === undefined) {
84
- return invalidError();
85
- }
86
- const quantifier = pattern.charAt(index + 1);
87
- const groupQuantified = quantifier === "+" || quantifier === "*" || quantifier === "?";
88
- const braceQuantified = quantifier === "{";
89
- if ((groupQuantified || braceQuantified) && quantifiedInside) {
90
- return nestedError();
91
- }
92
- quantifiedInside =
93
- frame.quantifiedInside || groupQuantified || braceQuantified;
29
+ if (next === "k") {
30
+ return backreferenceError();
31
+ }
32
+ state.index += 2;
33
+ return undefined;
34
+ }
35
+ function scanPatternClass(pattern, state) {
36
+ let index = state.index + 1;
37
+ if (pattern.charAt(index) === "^") {
38
+ index += 1;
39
+ }
40
+ if (pattern.charAt(index) === "]") {
41
+ index += 1;
42
+ }
43
+ while (index < pattern.length && pattern.charAt(index) !== "]") {
44
+ if (pattern.charAt(index) === "\\") {
94
45
  index += 1;
95
- continue;
96
46
  }
97
- if (current === "+" || current === "*" || current === "?") {
98
- quantifiedInside = true;
99
- index += 1;
100
- continue;
47
+ index += 1;
48
+ }
49
+ state.index = index + 1;
50
+ }
51
+ function openPatternGroup(pattern, state) {
52
+ state.groups.push({ quantifiedInside: state.quantifiedInside });
53
+ state.quantifiedInside = false;
54
+ const afterParen = state.index + 1;
55
+ // SPEC §9.2: a ? directly after ( opens a non-capturing or
56
+ // lookaround group; it quantifies nothing.
57
+ if (pattern.charAt(afterParen) !== "?") {
58
+ state.index = afterParen;
59
+ return;
60
+ }
61
+ const marker = pattern.charAt(afterParen + 1);
62
+ if (marker === ":" || marker === "=" || marker === "!") {
63
+ state.index = afterParen + 2;
64
+ return;
65
+ }
66
+ if (marker === "<") {
67
+ const close = pattern.indexOf(">", afterParen + 1);
68
+ state.index = close >= 0 ? close + 1 : afterParen + 1;
69
+ return;
70
+ }
71
+ state.index = afterParen + 1;
72
+ }
73
+ function isGroupQuantifier(quantifier) {
74
+ return (quantifier === "+" ||
75
+ quantifier === "*" ||
76
+ quantifier === "?" ||
77
+ quantifier === "{");
78
+ }
79
+ function closePatternGroup(pattern, state) {
80
+ const frame = state.groups.pop();
81
+ if (frame === undefined) {
82
+ return invalidError();
83
+ }
84
+ const quantified = isGroupQuantifier(pattern.charAt(state.index + 1));
85
+ if (quantified && state.quantifiedInside) {
86
+ return nestedError();
87
+ }
88
+ state.quantifiedInside = frame.quantifiedInside || quantified;
89
+ state.index += 1;
90
+ return undefined;
91
+ }
92
+ function markPatternQuantifier(pattern, state) {
93
+ const current = pattern.charAt(state.index);
94
+ if (current === "{") {
95
+ if (BRACE_QUANTIFIER_PATTERN.test(pattern.slice(state.index))) {
96
+ state.quantifiedInside = true;
101
97
  }
102
- if (current === "{") {
103
- const rest = pattern.slice(index);
104
- if (/^\{\d+(,\d*)?\}/.test(rest)) {
105
- quantifiedInside = true;
106
- }
107
- index += 1;
108
- continue;
98
+ }
99
+ else {
100
+ state.quantifiedInside = true;
101
+ }
102
+ state.index += 1;
103
+ }
104
+ function isQuantifierStart(current) {
105
+ return (current === "+" || current === "*" || current === "?" || current === "{");
106
+ }
107
+ function scanPatternStep(pattern, state) {
108
+ const current = pattern.charAt(state.index);
109
+ if (current === "\\") {
110
+ return scanPatternEscape(pattern, state);
111
+ }
112
+ if (current === "[") {
113
+ scanPatternClass(pattern, state);
114
+ return undefined;
115
+ }
116
+ if (current === "(") {
117
+ openPatternGroup(pattern, state);
118
+ return undefined;
119
+ }
120
+ if (current === ")") {
121
+ return closePatternGroup(pattern, state);
122
+ }
123
+ if (isQuantifierStart(current)) {
124
+ markPatternQuantifier(pattern, state);
125
+ return undefined;
126
+ }
127
+ state.index += 1;
128
+ return undefined;
129
+ }
130
+ function scanPatternBody(pattern) {
131
+ const state = { groups: [], quantifiedInside: false, index: 0 };
132
+ while (state.index < pattern.length) {
133
+ const failed = scanPatternStep(pattern, state);
134
+ if (failed !== undefined) {
135
+ return failed;
109
136
  }
110
- index += 1;
111
137
  }
112
- if (groups.length > 0) {
138
+ if (state.groups.length > 0) {
113
139
  return invalidError();
114
140
  }
115
141
  return ok(undefined);
116
142
  }
143
+ // SPEC §9.2: scan with escape and character-class awareness so an escaped
144
+ // parenthesis or a bracketed quantifier cannot hide a nested quantifier.
145
+ export function checkPattern(pattern) {
146
+ if (pattern.length > MAX_PATTERN_LENGTH) {
147
+ return tooLongError();
148
+ }
149
+ return scanPatternBody(pattern);
150
+ }
@@ -15,37 +15,51 @@ export const DEFAULT_FIXTURE_GLOBS = [
15
15
  "**/test-data/**",
16
16
  "**/*.fixture.*",
17
17
  ];
18
+ // The home-prefix patterns live at module scope so their quantifiers
19
+ // never count as branches of the function that applies them.
20
+ const HOME_PREFIX_PATTERNS = [
21
+ /^~[/\\]?/,
22
+ /^\$home[/\\]?/,
23
+ /^\$\{home\}[/\\]?/,
24
+ /^%userprofile%[/\\]?/,
25
+ ];
26
+ function isEnvSecretFile(base) {
27
+ return (base === ".env" || (base.startsWith(".env.") && base !== ".env.example"));
28
+ }
29
+ function isKeyMaterialFile(base) {
30
+ return (base.endsWith(".pem") ||
31
+ base.endsWith(".key") ||
32
+ base.endsWith(".p12") ||
33
+ base.endsWith(".pfx") ||
34
+ base.startsWith("id_rsa") ||
35
+ base.startsWith("id_ed25519"));
36
+ }
37
+ function homeRelativePath(slashed) {
38
+ let relative = slashed.toLowerCase();
39
+ for (const pattern of HOME_PREFIX_PATTERNS) {
40
+ relative = relative.replace(pattern, "");
41
+ }
42
+ return relative;
43
+ }
44
+ function isSecretDirPath(slashed) {
45
+ const segments = homeRelativePath(slashed).split("/");
46
+ return (segments.some((segment) => SECRET_DIR_SEGMENTS.has(segment)) ||
47
+ (segments[0] === ".config" && segments[1] === "gh"));
48
+ }
18
49
  // SPEC §4.7.1: the secret-read guard paths.
19
50
  export function isSecretReadPath(path) {
20
51
  const slashed = path.replace(/\\/g, "/");
21
52
  const base = slashed.split("/").pop() ?? "";
22
- if (base === ".env" ||
23
- (base.startsWith(".env.") && base !== ".env.example")) {
53
+ if (isEnvSecretFile(base)) {
24
54
  return true;
25
55
  }
26
- if (base.endsWith(".pem") ||
27
- base.endsWith(".key") ||
28
- base.endsWith(".p12") ||
29
- base.endsWith(".pfx") ||
30
- base.startsWith("id_rsa") ||
31
- base.startsWith("id_ed25519")) {
56
+ if (isKeyMaterialFile(base)) {
32
57
  return true;
33
58
  }
34
59
  if (SECRET_BASENAMES.has(base)) {
35
60
  return true;
36
61
  }
37
- const lowered = slashed.toLowerCase();
38
- const homeRelative = lowered
39
- .replace(/^~[/\\]?/, "")
40
- .replace(/^\$home[/\\]?/, "")
41
- .replace(/^\$\{home\}[/\\]?/, "")
42
- .replace(/^%userprofile%[/\\]?/, "");
43
- const segments = homeRelative.split("/");
44
- if (segments.some((segment) => SECRET_DIR_SEGMENTS.has(segment)) ||
45
- (segments[0] === ".config" && segments[1] === "gh")) {
46
- return true;
47
- }
48
- return false;
62
+ return isSecretDirPath(slashed);
49
63
  }
50
64
  // SPEC §4.7.1: placeholder credentials and test vectors under a declared
51
65
  // fixture glob are allowlisted. The kernel default covers the common
@@ -85,6 +85,25 @@ function skipFlagValues(words, valueFlags) {
85
85
  }
86
86
  return kept;
87
87
  }
88
+ const SUDO_VALUE_FLAGS = new Set([
89
+ "-u",
90
+ "--user",
91
+ "-g",
92
+ "--group",
93
+ ]);
94
+ function stripSudoPrefix(rest) {
95
+ const skipped = skipFlagValues(rest.slice(1), SUDO_VALUE_FLAGS);
96
+ if (skipped.length > 0 &&
97
+ (skipped[0] === "-c" || skipped[0] === "--command")) {
98
+ const script = skipped[1];
99
+ return {
100
+ rest: [],
101
+ scripts: script === undefined ? [] : [script],
102
+ bare: true,
103
+ };
104
+ }
105
+ return { rest: skipped, scripts: [], bare: false };
106
+ }
88
107
  function stripPrefixes(words) {
89
108
  let rest = [...words];
90
109
  let raised = false;
@@ -100,13 +119,11 @@ function stripPrefixes(words) {
100
119
  }
101
120
  const lowered = baseName(head).toLowerCase();
102
121
  if (lowered === "sudo" || lowered === "su") {
122
+ const sudo = stripSudoPrefix(rest);
103
123
  raised = true;
104
- rest = skipFlagValues(rest.slice(1), new Set(["-u", "--user", "-g", "--group"]));
105
- if (rest.length > 0 && (rest[0] === "-c" || rest[0] === "--command")) {
106
- const script = rest[1];
107
- if (script !== undefined) {
108
- scripts.push(script);
109
- }
124
+ rest = [...sudo.rest];
125
+ scripts.push(...sudo.scripts);
126
+ if (sudo.bare) {
110
127
  return { rest: [], raised, scripts, bare: true };
111
128
  }
112
129
  continue;